1
Fork 0

Merge pull request #309 from photoview/typescript

Add Typescript for UI
This commit is contained in:
Viktor Strate Kløvedal 2021-04-13 21:46:10 +02:00 committed by GitHub
commit 6ad9181887
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
132 changed files with 4820 additions and 1556 deletions

1
.gitignore vendored
View File

@ -21,6 +21,7 @@ node_modules/
.cache/ .cache/
dist/ dist/
build/ build/
*.tsbuildinfo
# misc # misc
.DS_Store .DS_Store

View File

@ -72,6 +72,16 @@ func migrate_exif_fields_exposure(db *gorm.DB) error {
return tx.Model(&exifModel{}).Table("media_exif").Where("exposure LIKE '%/%'").FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error { return tx.Model(&exifModel{}).Table("media_exif").Where("exposure LIKE '%/%'").FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error {
for _, result := range results { for _, result := range results {
if result.Exposure == nil {
continue
}
if *result.Exposure == "" {
result.Exposure = nil
continue
}
frac := strings.Split(*result.Exposure, "/") frac := strings.Split(*result.Exposure, "/")
if len(frac) != 2 { if len(frac) != 2 {
return errors.Errorf("failed to convert exposure value (%s) expected format x/y", frac) return errors.Errorf("failed to convert exposure value (%s) expected format x/y", frac)
@ -147,6 +157,16 @@ func migrate_exif_fields_flash(db *gorm.DB) error {
return tx.Model(&exifModel{}).Table("media_exif").Where("flash IS NOT NULL").FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error { return tx.Model(&exifModel{}).Table("media_exif").Where("flash IS NOT NULL").FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error {
for _, result := range results { for _, result := range results {
if result.Flash == nil {
continue
}
if *result.Flash == "" {
result.Flash = nil
continue
}
for index, name := range flashDescriptions { for index, name := range flashDescriptions {
if *result.Flash == name { if *result.Flash == name {
*result.Flash = fmt.Sprintf("%d", index) *result.Flash = fmt.Sprintf("%d", index)

View File

@ -1715,29 +1715,29 @@ type Mutation {
scanUser(userId: ID!): ScannerResult! @isAdmin scanUser(userId: ID!): ScannerResult! @isAdmin
"Generate share token for album" "Generate share token for album"
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken @isAuthorized shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
"Generate share token for media" "Generate share token for media"
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken @isAuthorized shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
"Delete a share token by it's token value" "Delete a share token by it's token value"
deleteShareToken(token: String!): ShareToken @isAuthorized deleteShareToken(token: String!): ShareToken! @isAuthorized
"Set a password for a token, if null is passed for the password argument, the password will be cleared" "Set a password for a token, if null is passed for the password argument, the password will be cleared"
protectShareToken(token: String!, password: String): ShareToken @isAuthorized protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
"Mark or unmark a media as being a favorite" "Mark or unmark a media as being a favorite"
favoriteMedia(mediaId: ID!, favorite: Boolean!): Media @isAuthorized favoriteMedia(mediaId: ID!, favorite: Boolean!): Media! @isAuthorized
updateUser( updateUser(
id: ID! id: ID!
username: String username: String
password: String password: String
admin: Boolean admin: Boolean
): User @isAdmin ): User! @isAdmin
createUser( createUser(
username: String! username: String!
password: String password: String
admin: Boolean! admin: Boolean!
): User @isAdmin ): User! @isAdmin
deleteUser(id: ID!): User @isAdmin deleteUser(id: ID!): User! @isAdmin
"Add a root path from where to look for media for the given user" "Add a root path from where to look for media for the given user"
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
@ -1842,8 +1842,8 @@ type User {
} }
enum LanguageTranslation { enum LanguageTranslation {
en, English,
da Danish
} }
type UserPreferences { type UserPreferences {
@ -1899,8 +1899,8 @@ type MediaDownload {
} }
enum MediaType { enum MediaType {
photo Photo
video Video
} }
type Media { type Media {
@ -5084,11 +5084,14 @@ func (ec *executionContext) _Mutation_shareAlbum(ctx context.Context, field grap
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.ShareToken) res := resTmp.(*models.ShareToken)
fc.Result = res fc.Result = res
return ec.marshalOShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res) return ec.marshalNShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_shareMedia(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_shareMedia(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -5143,11 +5146,14 @@ func (ec *executionContext) _Mutation_shareMedia(ctx context.Context, field grap
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.ShareToken) res := resTmp.(*models.ShareToken)
fc.Result = res fc.Result = res
return ec.marshalOShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res) return ec.marshalNShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_deleteShareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_deleteShareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -5202,11 +5208,14 @@ func (ec *executionContext) _Mutation_deleteShareToken(ctx context.Context, fiel
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.ShareToken) res := resTmp.(*models.ShareToken)
fc.Result = res fc.Result = res
return ec.marshalOShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res) return ec.marshalNShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_protectShareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_protectShareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -5261,11 +5270,14 @@ func (ec *executionContext) _Mutation_protectShareToken(ctx context.Context, fie
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.ShareToken) res := resTmp.(*models.ShareToken)
fc.Result = res fc.Result = res
return ec.marshalOShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res) return ec.marshalNShareToken2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_favoriteMedia(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_favoriteMedia(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -5320,11 +5332,14 @@ func (ec *executionContext) _Mutation_favoriteMedia(ctx context.Context, field g
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.Media) res := resTmp.(*models.Media)
fc.Result = res fc.Result = res
return ec.marshalOMedia2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res) return ec.marshalNMedia2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_updateUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_updateUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -5379,11 +5394,14 @@ func (ec *executionContext) _Mutation_updateUser(ctx context.Context, field grap
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.User) res := resTmp.(*models.User)
fc.Result = res fc.Result = res
return ec.marshalOUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res) return ec.marshalNUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_createUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_createUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -5438,11 +5456,14 @@ func (ec *executionContext) _Mutation_createUser(ctx context.Context, field grap
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.User) res := resTmp.(*models.User)
fc.Result = res fc.Result = res
return ec.marshalOUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res) return ec.marshalNUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_deleteUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_deleteUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -5497,11 +5518,14 @@ func (ec *executionContext) _Mutation_deleteUser(ctx context.Context, field grap
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*models.User) res := resTmp.(*models.User)
fc.Result = res fc.Result = res
return ec.marshalOUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res) return ec.marshalNUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_userAddRootPath(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_userAddRootPath(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
@ -10520,20 +10544,44 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
} }
case "shareAlbum": case "shareAlbum":
out.Values[i] = ec._Mutation_shareAlbum(ctx, field) out.Values[i] = ec._Mutation_shareAlbum(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "shareMedia": case "shareMedia":
out.Values[i] = ec._Mutation_shareMedia(ctx, field) out.Values[i] = ec._Mutation_shareMedia(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "deleteShareToken": case "deleteShareToken":
out.Values[i] = ec._Mutation_deleteShareToken(ctx, field) out.Values[i] = ec._Mutation_deleteShareToken(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "protectShareToken": case "protectShareToken":
out.Values[i] = ec._Mutation_protectShareToken(ctx, field) out.Values[i] = ec._Mutation_protectShareToken(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "favoriteMedia": case "favoriteMedia":
out.Values[i] = ec._Mutation_favoriteMedia(ctx, field) out.Values[i] = ec._Mutation_favoriteMedia(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "updateUser": case "updateUser":
out.Values[i] = ec._Mutation_updateUser(ctx, field) out.Values[i] = ec._Mutation_updateUser(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "createUser": case "createUser":
out.Values[i] = ec._Mutation_createUser(ctx, field) out.Values[i] = ec._Mutation_createUser(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "deleteUser": case "deleteUser":
out.Values[i] = ec._Mutation_deleteUser(ctx, field) out.Values[i] = ec._Mutation_deleteUser(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "userAddRootPath": case "userAddRootPath":
out.Values[i] = ec._Mutation_userAddRootPath(ctx, field) out.Values[i] = ec._Mutation_userAddRootPath(ctx, field)
case "userRemoveRootAlbum": case "userRemoveRootAlbum":
@ -12685,13 +12733,6 @@ func (ec *executionContext) marshalOTime2ᚖtimeᚐTime(ctx context.Context, sel
return graphql.MarshalTime(*v) return graphql.MarshalTime(*v)
} }
func (ec *executionContext) marshalOUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx context.Context, sel ast.SelectionSet, v *models.User) graphql.Marshaler {
if v == nil {
return graphql.Null
}
return ec._User(ctx, sel, v)
}
func (ec *executionContext) marshalOVideoMetadata2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐVideoMetadata(ctx context.Context, sel ast.SelectionSet, v *models.VideoMetadata) graphql.Marshaler { func (ec *executionContext) marshalOVideoMetadata2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐVideoMetadata(ctx context.Context, sel ast.SelectionSet, v *models.VideoMetadata) graphql.Marshaler {
if v == nil { if v == nil {
return graphql.Null return graphql.Null

View File

@ -71,18 +71,18 @@ type TimelineGroup struct {
type LanguageTranslation string type LanguageTranslation string
const ( const (
LanguageTranslationEn LanguageTranslation = "en" LanguageTranslationEnglish LanguageTranslation = "English"
LanguageTranslationDa LanguageTranslation = "da" LanguageTranslationDanish LanguageTranslation = "Danish"
) )
var AllLanguageTranslation = []LanguageTranslation{ var AllLanguageTranslation = []LanguageTranslation{
LanguageTranslationEn, LanguageTranslationEnglish,
LanguageTranslationDa, LanguageTranslationDanish,
} }
func (e LanguageTranslation) IsValid() bool { func (e LanguageTranslation) IsValid() bool {
switch e { switch e {
case LanguageTranslationEn, LanguageTranslationDa: case LanguageTranslationEnglish, LanguageTranslationDanish:
return true return true
} }
return false return false
@ -112,8 +112,8 @@ func (e LanguageTranslation) MarshalGQL(w io.Writer) {
type MediaType string type MediaType string
const ( const (
MediaTypePhoto MediaType = "photo" MediaTypePhoto MediaType = "Photo"
MediaTypeVideo MediaType = "video" MediaTypeVideo MediaType = "Video"
) )
var AllMediaType = []MediaType{ var AllMediaType = []MediaType{

View File

@ -42,6 +42,29 @@ func (m *Media) BeforeSave(tx *gorm.DB) error {
// Update path hash // Update path hash
m.PathHash = MD5Hash(m.Path) m.PathHash = MD5Hash(m.Path)
// Save media type as lowercase for better compatibility
m.Type = MediaType(strings.ToLower(string(m.Type)))
return nil
}
func (m *Media) AfterFind(tx *gorm.DB) error {
// Convert lowercased media type back
lowercasedType := strings.ToLower(string(m.Type))
foundType := false
for _, t := range AllMediaType {
if strings.ToLower(string(m.Type)) == lowercasedType {
m.Type = t
foundType = true
break
}
}
if foundType == false {
return errors.New(fmt.Sprintf("Failed to parse media from DB: Invalid media type: %s", m.Type))
}
return nil return nil
} }

View File

@ -95,29 +95,29 @@ type Mutation {
scanUser(userId: ID!): ScannerResult! @isAdmin scanUser(userId: ID!): ScannerResult! @isAdmin
"Generate share token for album" "Generate share token for album"
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken @isAuthorized shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
"Generate share token for media" "Generate share token for media"
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken @isAuthorized shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
"Delete a share token by it's token value" "Delete a share token by it's token value"
deleteShareToken(token: String!): ShareToken @isAuthorized deleteShareToken(token: String!): ShareToken! @isAuthorized
"Set a password for a token, if null is passed for the password argument, the password will be cleared" "Set a password for a token, if null is passed for the password argument, the password will be cleared"
protectShareToken(token: String!, password: String): ShareToken @isAuthorized protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
"Mark or unmark a media as being a favorite" "Mark or unmark a media as being a favorite"
favoriteMedia(mediaId: ID!, favorite: Boolean!): Media @isAuthorized favoriteMedia(mediaId: ID!, favorite: Boolean!): Media! @isAuthorized
updateUser( updateUser(
id: ID! id: ID!
username: String username: String
password: String password: String
admin: Boolean admin: Boolean
): User @isAdmin ): User! @isAdmin
createUser( createUser(
username: String! username: String!
password: String password: String
admin: Boolean! admin: Boolean!
): User @isAdmin ): User! @isAdmin
deleteUser(id: ID!): User @isAdmin deleteUser(id: ID!): User! @isAdmin
"Add a root path from where to look for media for the given user" "Add a root path from where to look for media for the given user"
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
@ -222,8 +222,8 @@ type User {
} }
enum LanguageTranslation { enum LanguageTranslation {
en, English,
da Danish
} }
type UserPreferences { type UserPreferences {
@ -279,8 +279,8 @@ type MediaDownload {
} }
enum MediaType { enum MediaType {
photo Photo
video Video
} }
type Media { type Media {

View File

@ -1,9 +1,18 @@
module.exports = { module.exports = {
root: true,
parser: '@typescript-eslint/parser',
env: { env: {
browser: true, browser: true,
es6: true, es6: true,
}, },
extends: ['eslint:recommended', 'plugin:react/recommended'], ignorePatterns: ['node_modules', 'dist'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
globals: { globals: {
Atomics: 'readonly', Atomics: 'readonly',
SharedArrayBuffer: 'readonly', SharedArrayBuffer: 'readonly',
@ -18,17 +27,20 @@ module.exports = {
ecmaVersion: 2018, ecmaVersion: 2018,
sourceType: 'module', sourceType: 'module',
}, },
plugins: ['react', 'react-hooks'], plugins: ['react', 'react-hooks', '@typescript-eslint'],
rules: { rules: {
'no-unused-vars': 'warn', 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'react/display-name': 'off', 'react/display-name': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
}, },
settings: { settings: {
react: { react: {
version: 'detect', version: 'detect',
}, },
}, },
parser: 'babel-eslint', // parser: 'babel-eslint',
overrides: [ overrides: [
Object.assign(require('eslint-plugin-jest').configs.recommended, { Object.assign(require('eslint-plugin-jest').configs.recommended, {
files: ['**/*.test.js'], files: ['**/*.test.js'],
@ -42,5 +54,11 @@ module.exports = {
} }
), ),
}), }),
{
files: ['**/*.js'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
},
], ],
} }

33
ui/__generated__/globalTypes.ts generated Normal file
View File

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
//==============================================================
// START Enums and Input Objects
//==============================================================
export enum LanguageTranslation {
Danish = "Danish",
English = "English",
}
export enum MediaType {
Photo = "Photo",
Video = "Video",
}
export enum NotificationType {
Close = "Close",
Message = "Message",
Progress = "Progress",
}
export enum OrderDirection {
ASC = "ASC",
DESC = "DESC",
}
//==============================================================
// END Enums and Input Objects
//==============================================================

8
ui/apollo.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
client: {
service: {
name: 'photoview',
localSchemaFile: '../api/graphql/schema.graphql',
},
},
}

View File

@ -2,27 +2,25 @@ module.exports = function (api) {
const isTest = api.env('test') const isTest = api.env('test')
const isProduction = api.env('NODE_ENV') == 'production' const isProduction = api.env('NODE_ENV') == 'production'
let presets = ['@babel/preset-react'] let presets = ['@babel/preset-react', '@babel/preset-typescript']
let plugins = [] let plugins = []
if (isTest) { if (isTest) {
presets.push('@babel/preset-env') presets.push('@babel/preset-env')
plugins.push('@babel/plugin-transform-runtime') plugins.push('@babel/plugin-transform-runtime')
plugins.push('@babel/plugin-transform-modules-commonjs')
} else { } else {
plugins.push(['styled-components', { pure: true }])
plugins.push('graphql-tag')
if (!isProduction) { if (!isProduction) {
plugins.push([ plugins.push([
'i18next-extract', 'i18next-extract',
{ {
locales: ['en', 'da'], locales: ['en', 'da'],
discardOldKeys: true,
defaultValue: null, defaultValue: null,
}, },
]) ])
} }
plugins.push(['styled-components', { pure: true }])
plugins.push('graphql-tag')
} }
return { return {

View File

@ -20,10 +20,10 @@ const defineEnv = ENVIRONMENT_VARIABLES.reduce((acc, key) => {
}, {}) }, {})
const esbuildOptions = { const esbuildOptions = {
entryPoints: ['src/index.js'], entryPoints: ['src/index.tsx'],
plugins: [ plugins: [
babel({ babel({
filter: /photoview\/ui\/src\/.*\.js$/, filter: /photoview\/ui\/src\/.*\.(js|tsx?)$/,
}), }),
], ],
publicPath: process.env.UI_PUBLIC_URL || '/', publicPath: process.env.UI_PUBLIC_URL || '/',
@ -66,25 +66,25 @@ if (watchMode) {
open: false, open: false,
}) })
bs.watch('src/**/*.js').on('change', async args => { bs.watch('src/**/*.@(js|tsx|ts)').on('change', async args => {
console.log('reloading', args) console.log('reloading', args)
builderPromise = (await builderPromise).rebuild() builderPromise = (await builderPromise).rebuild()
bs.reload(args) // bs.reload(args)
}) })
} else { } else {
const esbuildPromise = esbuild const build = async () => {
.build(esbuildOptions) await esbuild.build(esbuildOptions)
.then(() => console.log('esbuild done'))
const workboxPromise = workboxBuild console.log('esbuild done')
.generateSW({
await workboxBuild.generateSW({
globDirectory: 'dist/', globDirectory: 'dist/',
globPatterns: ['**/*.{png,svg,woff2,ttf,eot,woff,js,ico,html,json,css}'], globPatterns: ['**/*.{png,svg,woff2,ttf,eot,woff,js,ico,html,json,css}'],
swDest: 'dist/service-worker.js', swDest: 'dist/service-worker.js',
}) })
.then(() => console.log('workbox done'))
Promise.all([esbuildPromise, workboxPromise]).then(() => console.log('workbox done')
console.log('build complete') console.log('build complete')
) }
build()
} }

View File

@ -62,6 +62,9 @@
}, },
"welcome": "Velkommen til Photoview" "welcome": "Velkommen til Photoview"
}, },
"meta": {
"description": "Simpelt og Brugervenligt Photo-galleri for Personlige Servere"
},
"people_page": { "people_page": {
"face_group": { "face_group": {
"label_placeholder": "Navn", "label_placeholder": "Navn",
@ -69,6 +72,9 @@
}, },
"recognize_unlabeled_faces_button": "Genkend ikke navngivede ansigter" "recognize_unlabeled_faces_button": "Genkend ikke navngivede ansigter"
}, },
"photos_page": {
"title": "Billeder"
},
"routes": { "routes": {
"page_not_found": "Side ikke fundet" "page_not_found": "Side ikke fundet"
}, },
@ -77,7 +83,7 @@
"description": "Det maksimale antal medier som må skannes samtidig", "description": "Det maksimale antal medier som må skannes samtidig",
"title": "Samtidige scanner-arbejdere" "title": "Samtidige scanner-arbejdere"
}, },
"logout": null, "logout": "Log ud",
"periodic_scanner": { "periodic_scanner": {
"checkbox_label": "Aktiver periodiske scanner", "checkbox_label": "Aktiver periodiske scanner",
"field": { "field": {
@ -99,6 +105,10 @@
"title": "Scanner" "title": "Scanner"
}, },
"user_preferences": { "user_preferences": {
"change_language": {
"description": "Set sidens sprog specifikt for denne bruger",
"label": "Sprog"
},
"language_selector": { "language_selector": {
"placeholder": "Vælg sprog" "placeholder": "Vælg sprog"
}, },
@ -143,6 +153,9 @@
} }
}, },
"share_page": { "share_page": {
"media": {
"title": "Delt medie"
},
"protected_share": { "protected_share": {
"description": "Denne deling er låst med en adgangskode.", "description": "Denne deling er låst med en adgangskode.",
"title": "Beskyttet deling" "title": "Beskyttet deling"
@ -205,7 +218,7 @@
"exposure": "Lukketid", "exposure": "Lukketid",
"exposure_program": "Lukketid program", "exposure_program": "Lukketid program",
"flash": "Blitz", "flash": "Blitz",
"focal_length": "Fokallængde", "focal_length": "Brændvidde",
"iso": "ISO", "iso": "ISO",
"lens": "Lense", "lens": "Lense",
"maker": "Mærke" "maker": "Mærke"

View File

@ -62,6 +62,9 @@
}, },
"welcome": "Welcome to Photoview" "welcome": "Welcome to Photoview"
}, },
"meta": {
"description": "Simple and User-friendly Photo Gallery for Personal Servers"
},
"people_page": { "people_page": {
"face_group": { "face_group": {
"label_placeholder": "Label", "label_placeholder": "Label",
@ -69,6 +72,9 @@
}, },
"recognize_unlabeled_faces_button": "Recognize unlabeled faces" "recognize_unlabeled_faces_button": "Recognize unlabeled faces"
}, },
"photos_page": {
"title": "Photos"
},
"routes": { "routes": {
"page_not_found": "Page not found" "page_not_found": "Page not found"
}, },
@ -99,6 +105,10 @@
"title": "Scanner" "title": "Scanner"
}, },
"user_preferences": { "user_preferences": {
"change_language": {
"description": "Change website language specific for this user",
"label": "Website language"
},
"language_selector": { "language_selector": {
"placeholder": "Select language" "placeholder": "Select language"
}, },
@ -143,6 +153,9 @@
} }
}, },
"share_page": { "share_page": {
"media": {
"title": "Shared media"
},
"protected_share": { "protected_share": {
"description": "This share is protected with a password.", "description": "This share is protected with a password.",
"title": "Protected share" "title": "Protected share"

1437
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,10 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"description": "UI app for Photoview", "description": "UI app for Photoview",
"dependencies": { "dependencies": {
"@apollo/client": "^3.3.13", "@apollo/client": "^3.3.14",
"@babel/core": "^7.13.14", "@babel/core": "^7.13.15",
"@babel/plugin-transform-modules-commonjs": "^7.13.8",
"@babel/plugin-transform-runtime": "^7.13.10",
"@babel/preset-env": "^7.13.12",
"@babel/preset-react": "^7.13.13", "@babel/preset-react": "^7.13.13",
"@babel/preset-typescript": "^7.13.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"babel-plugin-graphql-tag": "^3.2.0", "babel-plugin-graphql-tag": "^3.2.0",
@ -27,14 +25,13 @@
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"esbuild": "^0.8.52", "esbuild": "^0.8.52",
"esbuild-plugin-babel": "^0.2.3", "esbuild-plugin-babel": "^0.2.3",
"eslint": "^7.23.0", "eslint": "^7.24.0",
"eslint-plugin-jest": "^24.3.3", "eslint-plugin-jest": "^24.3.5",
"eslint-plugin-jest-dom": "^3.7.0", "eslint-plugin-jest-dom": "^3.8.0",
"eslint-plugin-react": "^7.23.1", "eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"fs-extra": "^9.1.0", "fs-extra": "^9.1.0",
"graphql": "^15.5.0", "i18next": "^20.2.1",
"i18next": "^20.1.0",
"mapbox-gl": "^2.2.0", "mapbox-gl": "^2.2.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^17.0.2", "react": "^17.0.2",
@ -49,6 +46,7 @@
"semantic-ui-react": "^2.0.3", "semantic-ui-react": "^2.0.3",
"styled-components": "^5.2.3", "styled-components": "^5.2.3",
"subscriptions-transport-ws": "^0.9.18", "subscriptions-transport-ws": "^0.9.18",
"typescript": "^4.2.4",
"url-join": "^4.0.1", "url-join": "^4.0.1",
"workbox-build": "^6.1.2" "workbox-build": "^6.1.2"
}, },
@ -56,18 +54,33 @@
"start": "node --experimental-modules build.mjs watch", "start": "node --experimental-modules build.mjs watch",
"build": "NODE_ENV=production node --experimental-modules build.mjs", "build": "NODE_ENV=production node --experimental-modules build.mjs",
"test": "npm run lint && npm run jest", "test": "npm run lint && npm run jest",
"lint": "eslint ./src --max-warnings 0 --cache", "lint": "npm run lint:types & npm run lint:eslint",
"lint:eslint": "eslint ./src --max-warnings 0 --cache --config .eslintrc.js",
"lint:types": "tsc --noemit",
"jest": "jest", "jest": "jest",
"genSchemaTypes": "npx apollo client:codegen --target=typescript",
"prepare": "(cd .. && npx husky install)" "prepare": "(cd .. && npx husky install)"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.13.15",
"@testing-library/jest-dom": "^5.11.10", "@testing-library/jest-dom": "^5.11.10",
"@testing-library/react": "^11.2.6", "@testing-library/react": "^11.2.6",
"@types/jest": "^26.0.22",
"@types/react": "^17.0.3", "@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"@types/react-helmet": "^6.1.1",
"@types/react-router-dom": "^5.1.7",
"@types/styled-components": "^5.1.9",
"@types/url-join": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"eslint-config-prettier": "^8.1.0",
"husky": "^6.0.0", "husky": "^6.0.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"lint-staged": "^10.5.4", "lint-staged": "^10.5.4",
"prettier": "^2.2.1" "prettier": "^2.2.1",
"tsc-files": "^1.1.2"
}, },
"cache": { "cache": {
"swDest": "service-worker.js" "swDest": "service-worker.js"
@ -84,16 +97,13 @@
"^.+\\.css$" "^.+\\.css$"
], ],
"transform": { "transform": {
"^.+\\.js$": "babel-jest", "^.+\\.(js|ts|tsx)$": "babel-jest",
"^.+\\.svg$": "<rootDir>/testing/transform-svg.js" "^.+\\.svg$": "<rootDir>/testing/transform-svg.js"
} }
}, },
"lint-staged": { "lint-staged": {
"*.{js,json,css,md,graphql}": "prettier --write", "*.{ts,tsx,js,json,css,md,graphql}": "prettier --write",
"*.js": "eslint --cache --fix --max-warnings 0" "*.{js,ts,tsx}": "eslint --cache --fix --max-warnings 0",
}, "*.{ts,tsx}": "tsc-files --noEmit"
"sideEffects": [ }
"./src/index.js",
"./src/localization.js"
]
} }

4
ui/src/@types/index.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const src: string
export default src
}

View File

@ -30,9 +30,12 @@ const GlobalStyle = createGlobalStyle`
` `
import 'semantic-ui-css/semantic.min.css' import 'semantic-ui-css/semantic.min.css'
import { siteTranslation } from './__generated__/siteTranslation'
import { LanguageTranslation } from '../__generated__/globalTypes'
import { useTranslation } from 'react-i18next'
const SITE_TRANSLATION = gql` const SITE_TRANSLATION = gql`
query { query siteTranslation {
myUserPreferences { myUserPreferences {
id id
language language
@ -41,7 +44,8 @@ const SITE_TRANSLATION = gql`
` `
const loadTranslations = () => { const loadTranslations = () => {
const [loadLang, { data }] = useLazyQuery(SITE_TRANSLATION) console.log('load translation')
const [loadLang, { data }] = useLazyQuery<siteTranslation>(SITE_TRANSLATION)
useEffect(() => { useEffect(() => {
if (authToken()) { if (authToken()) {
@ -50,13 +54,22 @@ const loadTranslations = () => {
}, [authToken()]) }, [authToken()])
useEffect(() => { useEffect(() => {
console.log('loading translations', data)
switch (data?.myUserPreferences.language) { switch (data?.myUserPreferences.language) {
case 'da': case LanguageTranslation.Danish:
import('../extractedTranslations/da/translation.json').then(danish => { import('../extractedTranslations/da/translation.json').then(danish => {
console.log('loading danish')
i18n.addResourceBundle('da', 'translation', danish) i18n.addResourceBundle('da', 'translation', danish)
i18n.changeLanguage('da') i18n.changeLanguage('da')
}) })
break break
case LanguageTranslation.English:
import('../extractedTranslations/en/translation.json').then(english => {
console.log('loading english')
i18n.addResourceBundle('en', 'translation', english)
i18n.changeLanguage('en')
})
break
default: default:
i18n.changeLanguage('en') i18n.changeLanguage('en')
} }
@ -64,6 +77,7 @@ const loadTranslations = () => {
} }
const App = () => { const App = () => {
const { t } = useTranslation()
loadTranslations() loadTranslations()
return ( return (
@ -71,7 +85,10 @@ const App = () => {
<Helmet> <Helmet>
<meta <meta
name="description" name="description"
content="Simple and User-friendly Photo Gallery for Personal Servers" content={t(
'meta.description',
'Simple and User-friendly Photo Gallery for Personal Servers'
)}
/> />
</Helmet> </Helmet>
<GlobalStyle /> <GlobalStyle />

View File

@ -11,7 +11,7 @@ import * as authentication from './helpers/authentication'
require('./localization').default() require('./localization').default()
jest.mock('./helpers/authentication.js') jest.mock('./helpers/authentication.ts')
test('Layout component', async () => { test('Layout component', async () => {
render( render(

View File

@ -1,4 +1,4 @@
import React from 'react' import React, { ReactChild } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import styled from 'styled-components' import styled from 'styled-components'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
@ -79,16 +79,28 @@ const SideButtonLink = styled(NavLink)`
} }
` `
const SideButton = props => { type SideButtonProps = {
return ( children: ReactChild | ReactChild[]
<SideButtonLink {...props} activeStyle={{ color: '#4183c4' }}> to: string
{props.children} exact: boolean
</SideButtonLink>
)
} }
SideButton.propTypes = { const SideButton = ({
children: PropTypes.any, children,
to,
exact,
...otherProps
}: SideButtonProps) => {
return (
<SideButtonLink
{...otherProps}
to={to}
exact={exact}
activeStyle={{ color: '#4183c4' }}
>
{children}
</SideButtonLink>
)
} }
const SideButtonLabel = styled.div` const SideButtonLabel = styled.div`
@ -130,7 +142,12 @@ export const SideMenu = () => {
) )
} }
const Layout = ({ children, title, ...otherProps }) => { type LayoutProps = {
children: React.ReactNode
title: string
}
const Layout = ({ children, title, ...otherProps }: LayoutProps) => {
return ( return (
<Container {...otherProps} data-testid="Layout"> <Container {...otherProps} data-testid="Layout">
<Helmet> <Helmet>

View File

@ -1,16 +1,16 @@
import React, { useCallback, useEffect } from 'react' import React, { useCallback, useEffect } from 'react'
import ReactRouterPropTypes from 'react-router-prop-types'
import { useQuery, gql } from '@apollo/client' import { useQuery, gql } from '@apollo/client'
import AlbumGallery from '../../components/albumGallery/AlbumGallery' import AlbumGallery from '../../components/albumGallery/AlbumGallery'
import PropTypes from 'prop-types'
import Layout from '../../Layout' import Layout from '../../Layout'
import useURLParameters from '../../hooks/useURLParameters' import useURLParameters, { UrlKeyValuePair } from '../../hooks/useURLParameters'
import useScrollPagination from '../../hooks/useScrollPagination' import useScrollPagination from '../../hooks/useScrollPagination'
import PaginateLoader from '../../components/PaginateLoader' import PaginateLoader from '../../components/PaginateLoader'
import LazyLoad from '../../helpers/LazyLoad' import LazyLoad from '../../helpers/LazyLoad'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { albumQuery, albumQueryVariables } from './__generated__/albumQuery'
import { OrderDirection } from '../../../__generated__/globalTypes'
const albumQuery = gql` const ALBUM_QUERY = gql`
query albumQuery( query albumQuery(
$id: ID! $id: ID!
$onlyFavorites: Boolean $onlyFavorites: Boolean
@ -61,7 +61,16 @@ const albumQuery = gql`
let refetchNeededAll = false let refetchNeededAll = false
let refetchNeededFavorites = false let refetchNeededFavorites = false
function AlbumPage({ match }) { type AlbumPageProps = {
match: {
params: {
id: string
subPage: string
}
}
}
function AlbumPage({ match }: AlbumPageProps) {
const albumId = match.params.id const albumId = match.params.id
const { t } = useTranslation() const { t } = useTranslation()
@ -69,14 +78,22 @@ function AlbumPage({ match }) {
const { getParam, setParam, setParams } = useURLParameters() const { getParam, setParam, setParams } = useURLParameters()
const onlyFavorites = getParam('favorites') == '1' ? true : false const onlyFavorites = getParam('favorites') == '1' ? true : false
const setOnlyFavorites = favorites => setParam('favorites', favorites ? 1 : 0) const setOnlyFavorites = (favorites: boolean) =>
setParam('favorites', favorites ? '1' : '0')
const orderBy = getParam('orderBy', 'date_shot') const orderBy = getParam('orderBy', 'date_shot')
const orderDirection = getParam('orderDirection', 'ASC')
const setOrdering = useCallback( const orderDirStr = getParam('orderDirection', 'ASC') || 'hello'
const orderDirection = orderDirStr as OrderDirection
type setOrderingFn = (args: {
orderBy?: string
orderDirection?: OrderDirection
}) => void
const setOrdering: setOrderingFn = useCallback(
({ orderBy, orderDirection }) => { ({ orderBy, orderDirection }) => {
let updatedParams = [] const updatedParams: UrlKeyValuePair[] = []
if (orderBy !== undefined) { if (orderBy !== undefined) {
updatedParams.push({ key: 'orderBy', value: orderBy }) updatedParams.push({ key: 'orderBy', value: orderBy })
} }
@ -89,7 +106,10 @@ function AlbumPage({ match }) {
[setParams] [setParams]
) )
const { loading, error, data, refetch, fetchMore } = useQuery(albumQuery, { const { loading, error, data, refetch, fetchMore } = useQuery<
albumQuery,
albumQueryVariables
>(ALBUM_QUERY, {
variables: { variables: {
id: albumId, id: albumId,
onlyFavorites, onlyFavorites,
@ -100,7 +120,10 @@ function AlbumPage({ match }) {
}, },
}) })
const { containerElem, finished: finishedLoadingMore } = useScrollPagination({ const {
containerElem,
finished: finishedLoadingMore,
} = useScrollPagination<albumQuery>({
loading, loading,
fetchMore, fetchMore,
data, data,
@ -151,7 +174,6 @@ function AlbumPage({ match }) {
ref={containerElem} ref={containerElem}
album={data && data.album} album={data && data.album}
loading={loading} loading={loading}
showFavoritesToggle
setOnlyFavorites={toggleFavorites} setOnlyFavorites={toggleFavorites}
onlyFavorites={onlyFavorites} onlyFavorites={onlyFavorites}
onFavorite={() => (refetchNeededAll = refetchNeededFavorites = true)} onFavorite={() => (refetchNeededAll = refetchNeededFavorites = true)}
@ -167,14 +189,4 @@ function AlbumPage({ match }) {
) )
} }
AlbumPage.propTypes = {
...ReactRouterPropTypes,
match: PropTypes.shape({
params: PropTypes.shape({
id: PropTypes.string,
subPage: PropTypes.string,
}),
}),
}
export default AlbumPage export default AlbumPage

View File

@ -0,0 +1,118 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { OrderDirection, MediaType } from "./../../../../__generated__/globalTypes";
// ====================================================
// GraphQL query operation: albumQuery
// ====================================================
export interface albumQuery_album_subAlbums_thumbnail_thumbnail {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface albumQuery_album_subAlbums_thumbnail {
__typename: "Media";
/**
* URL to display the media in a smaller resolution
*/
thumbnail: albumQuery_album_subAlbums_thumbnail_thumbnail | null;
}
export interface albumQuery_album_subAlbums {
__typename: "Album";
id: string;
title: string;
/**
* An image in this album used for previewing this album
*/
thumbnail: albumQuery_album_subAlbums_thumbnail | null;
}
export interface albumQuery_album_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 albumQuery_album_media_highRes {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface albumQuery_album_media_videoWeb {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface albumQuery_album_media {
__typename: "Media";
id: string;
type: MediaType;
/**
* URL to display the media in a smaller resolution
*/
thumbnail: albumQuery_album_media_thumbnail | null;
/**
* URL to display the photo in full resolution, will be null for videos
*/
highRes: albumQuery_album_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: albumQuery_album_media_videoWeb | null;
favorite: boolean;
}
export interface albumQuery_album {
__typename: "Album";
id: string;
title: string;
/**
* The albums contained in this album
*/
subAlbums: albumQuery_album_subAlbums[];
/**
* The media inside this album
*/
media: albumQuery_album_media[];
}
export interface albumQuery {
/**
* 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: albumQuery_album;
}
export interface albumQueryVariables {
id: string;
onlyFavorites?: boolean | null;
mediaOrderBy?: string | null;
mediaOrderDirection?: OrderDirection | null;
limit?: number | null;
offset?: number | null;
}

View File

@ -0,0 +1,41 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: getMyAlbums
// ====================================================
export interface getMyAlbums_myAlbums_thumbnail_thumbnail {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface getMyAlbums_myAlbums_thumbnail {
__typename: "Media";
/**
* URL to display the media in a smaller resolution
*/
thumbnail: getMyAlbums_myAlbums_thumbnail_thumbnail | null;
}
export interface getMyAlbums_myAlbums {
__typename: "Album";
id: string;
title: string;
/**
* An image in this album used for previewing this album
*/
thumbnail: getMyAlbums_myAlbums_thumbnail | null;
}
export interface getMyAlbums {
/**
* List of albums owned by the logged in user.
*/
myAlbums: getMyAlbums_myAlbums[];
}

View File

@ -7,6 +7,7 @@ import { Container } from './loginUtilities'
import { checkInitialSetupQuery, login } from './loginUtilities' import { checkInitialSetupQuery, login } from './loginUtilities'
import { authToken } from '../../helpers/authentication' import { authToken } from '../../helpers/authentication'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { CheckInitialSetup } from './__generated__/CheckInitialSetup'
const initialSetupMutation = gql` const initialSetupMutation = gql`
mutation InitialSetup( mutation InitialSetup(
@ -35,31 +36,13 @@ const InitialSetupPage = () => {
rootPath: '', rootPath: '',
}) })
const handleChange = (event, key) => {
const value = event.target.value
setState(prevState => ({
...prevState,
[key]: value,
}))
}
const signIn = (event, authorize) => {
event.preventDefault()
authorize({
variables: {
username: state.username,
password: state.password,
rootPath: state.rootPath,
},
})
}
if (authToken()) { if (authToken()) {
return <Redirect to="/" /> return <Redirect to="/" />
} }
const { data: initialSetupData } = useQuery(checkInitialSetupQuery) const { data: initialSetupData } = useQuery<CheckInitialSetup>(
checkInitialSetupQuery
)
const initialSetupRedirect = initialSetupData?.siteInfo const initialSetupRedirect = initialSetupData?.siteInfo
?.initialSetup ? null : ( ?.initialSetup ? null : (
<Redirect to="/" /> <Redirect to="/" />
@ -78,6 +61,29 @@ const InitialSetupPage = () => {
}, },
}) })
const handleChange = (
event: React.ChangeEvent<HTMLInputElement>,
key: string
) => {
const value = event.target.value
setState(prevState => ({
...prevState,
[key]: value,
}))
}
const signIn = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
authorize({
variables: {
username: state.username,
password: state.password,
rootPath: state.rootPath,
},
})
}
let errorMessage = null let errorMessage = null
if (authorizationData && !authorizationData.initialSetupWizard.success) { if (authorizationData && !authorizationData.initialSetupWizard.success) {
errorMessage = authorizationData.initialSetupWizard.status errorMessage = authorizationData.initialSetupWizard.status
@ -93,7 +99,7 @@ const InitialSetupPage = () => {
<Form <Form
style={{ width: 500, margin: 'auto' }} style={{ width: 500, margin: 'auto' }}
error={!!errorMessage} error={!!errorMessage}
onSubmit={e => signIn(e, authorize)} onSubmit={signIn}
loading={ loading={
authorizeLoading || authorizationData?.initialSetupWizard?.success authorizeLoading || authorizationData?.initialSetupWizard?.success
} }

View File

@ -2,7 +2,7 @@ import React, { useState, useCallback } from 'react'
import { useQuery, gql, useMutation } from '@apollo/client' import { useQuery, gql, useMutation } from '@apollo/client'
import { Redirect } from 'react-router-dom' import { Redirect } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import { Button, Form, Message, Header } from 'semantic-ui-react' import { Button, Form, Message, Header, HeaderProps } from 'semantic-ui-react'
import { checkInitialSetupQuery, login, Container } from './loginUtilities' import { checkInitialSetupQuery, login, Container } from './loginUtilities'
import { authToken } from '../../helpers/authentication' import { authToken } from '../../helpers/authentication'
@ -23,7 +23,7 @@ const StyledLogo = styled.img`
max-height: 128px; max-height: 128px;
` `
const LogoHeader = props => { const LogoHeader = (props: HeaderProps) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (

View File

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: Authorize
// ====================================================
export interface Authorize_authorizeUser {
__typename: "AuthorizeResult";
success: boolean;
status: string;
token: string | null;
}
export interface Authorize {
authorizeUser: Authorize_authorizeUser;
}
export interface AuthorizeVariables {
username: string;
password: string;
}

View File

@ -0,0 +1,17 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: CheckInitialSetup
// ====================================================
export interface CheckInitialSetup_siteInfo {
__typename: "SiteInfo";
initialSetup: boolean;
}
export interface CheckInitialSetup {
siteInfo: CheckInitialSetup_siteInfo;
}

View File

@ -0,0 +1,28 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: InitialSetup
// ====================================================
export interface InitialSetup_initialSetupWizard {
__typename: "AuthorizeResult";
success: boolean;
status: string;
token: string | null;
}
export interface InitialSetup {
/**
* Registers the initial user, can only be called if initialSetup from SiteInfo is true
*/
initialSetupWizard: InitialSetup_initialSetupWizard | null;
}
export interface InitialSetupVariables {
username: string;
password: string;
rootPath: string;
}

View File

@ -11,9 +11,9 @@ export const checkInitialSetupQuery = gql`
} }
` `
export function login(token) { export function login(token: string) {
saveTokenCookie(token) saveTokenCookie(token)
window.location = '/' window.location.href = '/'
} }
export const Container = styled(SemanticContainer)` export const Container = styled(SemanticContainer)`

View File

@ -39,7 +39,7 @@ export const MY_FACES_QUERY = gql`
` `
export const SET_GROUP_LABEL_MUTATION = gql` export const SET_GROUP_LABEL_MUTATION = gql`
mutation($groupID: ID!, $label: String) { mutation setGroupLabel($groupID: ID!, $label: String) {
setFaceGroupLabel(faceGroupID: $groupID, label: $label) { setFaceGroupLabel(faceGroupID: $groupID, label: $label) {
id id
label label

View File

@ -7,7 +7,7 @@ import { MY_FACES_QUERY } from '../PeoplePage'
import SelectFaceGroupTable from './SelectFaceGroupTable' import SelectFaceGroupTable from './SelectFaceGroupTable'
const COMBINE_FACES_MUTATION = gql` const COMBINE_FACES_MUTATION = gql`
mutation($destID: ID!, $srcID: ID!) { mutation combineFaces($destID: ID!, $srcID: ID!) {
combineFaceGroups( combineFaceGroups(
destinationFaceGroupID: $destID destinationFaceGroupID: $destID
sourceFaceGroupID: $srcID sourceFaceGroupID: $srcID

View File

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: combineFaces
// ====================================================
export interface combineFaces_combineFaceGroups {
__typename: "FaceGroup";
id: string;
}
export interface combineFaces {
/**
* Merge two face groups into a single one, all ImageFaces from source will be moved to destination
*/
combineFaceGroups: combineFaces_combineFaceGroups;
}
export interface combineFacesVariables {
destID: string;
srcID: string;
}

View File

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: detachImageFaces
// ====================================================
export interface detachImageFaces_detachImageFaces {
__typename: "FaceGroup";
id: string;
label: string | null;
}
export interface detachImageFaces {
/**
* Move a list of ImageFaces to a new face group
*/
detachImageFaces: detachImageFaces_detachImageFaces;
}
export interface detachImageFacesVariables {
faceIDs: string[];
}

View File

@ -0,0 +1,31 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: moveImageFaces
// ====================================================
export interface moveImageFaces_moveImageFaces_imageFaces {
__typename: "ImageFace";
id: string;
}
export interface moveImageFaces_moveImageFaces {
__typename: "FaceGroup";
id: string;
imageFaces: moveImageFaces_moveImageFaces_imageFaces[];
}
export interface moveImageFaces {
/**
* Move a list of ImageFaces to another face group
*/
moveImageFaces: moveImageFaces_moveImageFaces;
}
export interface moveImageFacesVariables {
faceIDs: string[];
destFaceGroupID: string;
}

View File

@ -0,0 +1,82 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MediaType } from "./../../../../../__generated__/globalTypes";
// ====================================================
// GraphQL query operation: singleFaceGroup
// ====================================================
export interface singleFaceGroup_faceGroup_imageFaces_rectangle {
__typename: "FaceRectangle";
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export interface singleFaceGroup_faceGroup_imageFaces_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 singleFaceGroup_faceGroup_imageFaces_media_highRes {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface singleFaceGroup_faceGroup_imageFaces_media {
__typename: "Media";
id: string;
type: MediaType;
title: string;
/**
* URL to display the media in a smaller resolution
*/
thumbnail: singleFaceGroup_faceGroup_imageFaces_media_thumbnail | null;
/**
* URL to display the photo in full resolution, will be null for videos
*/
highRes: singleFaceGroup_faceGroup_imageFaces_media_highRes | null;
favorite: boolean;
}
export interface singleFaceGroup_faceGroup_imageFaces {
__typename: "ImageFace";
id: string;
rectangle: singleFaceGroup_faceGroup_imageFaces_rectangle | null;
media: singleFaceGroup_faceGroup_imageFaces_media;
}
export interface singleFaceGroup_faceGroup {
__typename: "FaceGroup";
id: string;
label: string | null;
imageFaces: singleFaceGroup_faceGroup_imageFaces[];
}
export interface singleFaceGroup {
faceGroup: singleFaceGroup_faceGroup;
}
export interface singleFaceGroupVariables {
id: string;
limit: number;
offset: number;
}

View File

@ -0,0 +1,65 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: myFaces
// ====================================================
export interface myFaces_myFaceGroups_imageFaces_rectangle {
__typename: "FaceRectangle";
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export interface myFaces_myFaceGroups_imageFaces_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 myFaces_myFaceGroups_imageFaces_media {
__typename: "Media";
id: string;
/**
* URL to display the media in a smaller resolution
*/
thumbnail: myFaces_myFaceGroups_imageFaces_media_thumbnail | null;
}
export interface myFaces_myFaceGroups_imageFaces {
__typename: "ImageFace";
id: string;
rectangle: myFaces_myFaceGroups_imageFaces_rectangle | null;
media: myFaces_myFaceGroups_imageFaces_media;
}
export interface myFaces_myFaceGroups {
__typename: "FaceGroup";
id: string;
label: string | null;
imageFaceCount: number;
imageFaces: myFaces_myFaceGroups_imageFaces[];
}
export interface myFaces {
myFaceGroups: myFaces_myFaceGroups[];
}
export interface myFacesVariables {
limit?: number | null;
offset?: number | null;
}

View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: recognizeUnlabeledFaces
// ====================================================
export interface recognizeUnlabeledFaces_recognizeUnlabeledFaces {
__typename: "ImageFace";
id: string;
}
export interface recognizeUnlabeledFaces {
/**
* Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match
*/
recognizeUnlabeledFaces: recognizeUnlabeledFaces_recognizeUnlabeledFaces[];
}

View File

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: setGroupLabel
// ====================================================
export interface setGroupLabel_setFaceGroupLabel {
__typename: "FaceGroup";
id: string;
label: string | null;
}
export interface setGroupLabel {
/**
* Assign a label to a face group, set label to null to remove the current one
*/
setFaceGroupLabel: setGroupLabel_setFaceGroupLabel;
}
export interface setGroupLabelVariables {
groupID: string;
label?: string | null;
}

View File

@ -1,24 +1,18 @@
import React from 'react' import React from 'react'
import Layout from '../../Layout' import Layout from '../../Layout'
import PropTypes from 'prop-types'
import TimelineGallery from '../../components/timelineGallery/TimelineGallery' import TimelineGallery from '../../components/timelineGallery/TimelineGallery'
import { useTranslation } from 'react-i18next'
const PhotosPage = () => { const PhotosPage = () => {
const { t } = useTranslation()
return ( return (
<> <>
<Layout title="Photos"> <Layout title={t('photos_page.title', 'Photos')}>
<TimelineGallery /> <TimelineGallery />
</Layout> </Layout>
</> </>
) )
} }
PhotosPage.propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
subPage: PropTypes.string,
}),
}),
}
export default PhotosPage export default PhotosPage

View File

@ -0,0 +1,19 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: placePageMapboxToken
// ====================================================
export interface placePageMapboxToken {
/**
* 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,88 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MediaType } from "./../../../../__generated__/globalTypes";
// ====================================================
// GraphQL query operation: placePageQueryMedia
// ====================================================
export interface placePageQueryMedia_mediaList_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 placePageQueryMedia_mediaList_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 placePageQueryMedia_mediaList_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 placePageQueryMedia_mediaList {
__typename: "Media";
id: string;
title: string;
/**
* URL to display the media in a smaller resolution
*/
thumbnail: placePageQueryMedia_mediaList_thumbnail | null;
/**
* URL to display the photo in full resolution, will be null for videos
*/
highRes: placePageQueryMedia_mediaList_highRes | null;
/**
* URL to get the video in a web format that can be played in the browser, will be null for photos
*/
videoWeb: placePageQueryMedia_mediaList_videoWeb | null;
type: MediaType;
}
export interface placePageQueryMedia {
/**
* Get a list of media by their ids, user must own the media or be admin
*/
mediaList: placePageQueryMedia_mediaList[];
}
export interface placePageQueryMediaVariables {
mediaIDs: string[];
}

View File

@ -4,6 +4,11 @@ import { useMutation, useQuery } from '@apollo/client'
import { Checkbox, Dropdown, Input, Loader } from 'semantic-ui-react' import { Checkbox, Dropdown, Input, Loader } from 'semantic-ui-react'
import { InputLabelDescription, InputLabelTitle } from './SettingsPage' import { InputLabelDescription, InputLabelTitle } from './SettingsPage'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { scanIntervalQuery } from './__generated__/scanIntervalQuery'
import {
changeScanIntervalMutation,
changeScanIntervalMutationVariables,
} from './__generated__/changeScanIntervalMutation'
const SCAN_INTERVAL_QUERY = gql` const SCAN_INTERVAL_QUERY = gql`
query scanIntervalQuery { query scanIntervalQuery {
@ -19,44 +24,57 @@ const SCAN_INTERVAL_MUTATION = gql`
} }
` `
enum TimeUnit {
Second = 'second',
Minute = 'minute',
Hour = 'hour',
Day = 'day',
Month = 'month',
}
const timeUnits = [ const timeUnits = [
{ {
value: 'second', value: TimeUnit.Second,
multiplier: 1, multiplier: 1,
}, },
{ {
value: 'minute', value: TimeUnit.Minute,
multiplier: 60, multiplier: 60,
}, },
{ {
value: 'hour', value: TimeUnit.Hour,
multiplier: 60 * 60, multiplier: 60 * 60,
}, },
{ {
value: 'day', value: TimeUnit.Day,
multiplier: 60 * 60 * 24, multiplier: 60 * 60 * 24,
}, },
{ {
value: 'month', value: TimeUnit.Month,
multiplier: 60 * 60 * 24 * 30, multiplier: 60 * 60 * 24 * 30,
}, },
] ]
const convertToSeconds = ({ value, unit }) => { type TimeValue = {
return parseInt(value * timeUnits.find(x => x.value == unit).multiplier) value: number
unit: TimeUnit
} }
const convertToAppropriateUnit = ({ value, unit }) => { const convertToSeconds = ({ value, unit }: TimeValue) => {
return value * (timeUnits.find(x => x.value == unit)?.multiplier as number)
}
const convertToAppropriateUnit = ({ value, unit }: TimeValue): TimeValue => {
if (value == 0) { if (value == 0) {
return { return {
unit: 'second', unit: TimeUnit.Second,
value: 0, value: 0,
} }
} }
const seconds = convertToSeconds({ value, unit }) const seconds = convertToSeconds({ value, unit })
let resultingUnit = timeUnits.first let resultingUnit = timeUnits[0]
for (const unit of timeUnits) { for (const unit of timeUnits) {
if ( if (
seconds / unit.multiplier >= 1 && seconds / unit.multiplier >= 1 &&
@ -78,26 +96,26 @@ const PeriodicScanner = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [enablePeriodicScanner, setEnablePeriodicScanner] = useState(false) const [enablePeriodicScanner, setEnablePeriodicScanner] = useState(false)
const [scanInterval, setScanInterval] = useState({ const [scanInterval, setScanInterval] = useState<TimeValue>({
value: 4, value: 4,
unit: 'minute', unit: TimeUnit.Minute,
}) })
const scanIntervalServerValue = useRef(null) const scanIntervalServerValue = useRef<number | null>(null)
const scanIntervalQuery = useQuery(SCAN_INTERVAL_QUERY, { const scanIntervalQuery = useQuery<scanIntervalQuery>(SCAN_INTERVAL_QUERY, {
onCompleted(data) { onCompleted(data) {
const queryScanInterval = data.siteInfo.periodicScanInterval const queryScanInterval = data.siteInfo.periodicScanInterval
if (queryScanInterval == 0) { if (queryScanInterval == 0) {
setScanInterval({ setScanInterval({
unit: 'second', unit: TimeUnit.Second,
value: '', value: 0,
}) })
} else { } else {
setScanInterval( setScanInterval(
convertToAppropriateUnit({ convertToAppropriateUnit({
unit: 'second', unit: TimeUnit.Second,
value: queryScanInterval, value: queryScanInterval,
}) })
) )
@ -110,15 +128,20 @@ const PeriodicScanner = () => {
const [ const [
setScanIntervalMutation, setScanIntervalMutation,
{ loading: scanIntervalMutationLoading }, { loading: scanIntervalMutationLoading },
] = useMutation(SCAN_INTERVAL_MUTATION) ] = useMutation<
changeScanIntervalMutation,
changeScanIntervalMutationVariables
>(SCAN_INTERVAL_MUTATION)
const onScanIntervalCheckboxChange = checked => { const onScanIntervalCheckboxChange = (checked: boolean) => {
setEnablePeriodicScanner(checked) setEnablePeriodicScanner(checked)
onScanIntervalUpdate(checked ? scanInterval : { value: 0, unit: 'second' }) onScanIntervalUpdate(
checked ? scanInterval : { value: 0, unit: TimeUnit.Second }
)
} }
const onScanIntervalUpdate = scanInterval => { const onScanIntervalUpdate = (scanInterval: TimeValue) => {
const seconds = convertToSeconds(scanInterval) const seconds = convertToSeconds(scanInterval)
if (scanIntervalServerValue.current != seconds) { if (scanIntervalServerValue.current != seconds) {
@ -131,31 +154,37 @@ const PeriodicScanner = () => {
} }
} }
const scanIntervalUnits = [ type scanIntervalUnitType = {
key: TimeUnit
text: string
value: TimeUnit
}
const scanIntervalUnits: scanIntervalUnitType[] = [
{ {
key: 'second', key: TimeUnit.Second,
text: t('settings.periodic_scanner.interval_unit.seconds', 'Seconds'), text: t('settings.periodic_scanner.interval_unit.seconds', 'Seconds'),
value: 'second', value: TimeUnit.Second,
}, },
{ {
key: 'minute', key: TimeUnit.Minute,
text: t('settings.periodic_scanner.interval_unit.minutes', 'Minutes'), text: t('settings.periodic_scanner.interval_unit.minutes', 'Minutes'),
value: 'minute', value: TimeUnit.Minute,
}, },
{ {
key: 'hour', key: TimeUnit.Hour,
text: t('settings.periodic_scanner.interval_unit.hour', 'Hour'), text: t('settings.periodic_scanner.interval_unit.hour', 'Hour'),
value: 'hour', value: TimeUnit.Hour,
}, },
{ {
key: 'day', key: TimeUnit.Day,
text: t('settings.periodic_scanner.interval_unit.days', 'Days'), text: t('settings.periodic_scanner.interval_unit.days', 'Days'),
value: 'day', value: TimeUnit.Day,
}, },
{ {
key: 'month', key: TimeUnit.Month,
text: t('settings.periodic_scanner.interval_unit.months', 'Months'), text: t('settings.periodic_scanner.interval_unit.months', 'Months'),
value: 'month', value: TimeUnit.Month,
}, },
] ]
@ -171,7 +200,9 @@ const PeriodicScanner = () => {
)} )}
disabled={scanIntervalQuery.loading} disabled={scanIntervalQuery.loading}
checked={enablePeriodicScanner} checked={enablePeriodicScanner}
onChange={(_, { checked }) => onScanIntervalCheckboxChange(checked)} onChange={(_, { checked }) =>
onScanIntervalCheckboxChange(checked || false)
}
/> />
</div> </div>
@ -195,9 +226,9 @@ const PeriodicScanner = () => {
label={ label={
<Dropdown <Dropdown
onChange={(_, { value }) => { onChange={(_, { value }) => {
const newScanInterval = { const newScanInterval: TimeValue = {
...scanInterval, ...scanInterval,
unit: value, unit: value as TimeUnit,
} }
setScanInterval(newScanInterval) setScanInterval(newScanInterval)
@ -208,7 +239,7 @@ const PeriodicScanner = () => {
/> />
} }
onBlur={() => onScanIntervalUpdate(scanInterval)} onBlur={() => onScanIntervalUpdate(scanInterval)}
onKeyDown={({ key }) => onKeyDown={({ key }: KeyboardEvent) =>
key == 'Enter' && onScanIntervalUpdate(scanInterval) key == 'Enter' && onScanIntervalUpdate(scanInterval)
} }
loading={scanIntervalQuery.loading} loading={scanIntervalQuery.loading}
@ -219,7 +250,7 @@ const PeriodicScanner = () => {
onChange={(_, { value }) => { onChange={(_, { value }) => {
setScanInterval(x => ({ setScanInterval(x => ({
...x, ...x,
value, value: parseInt(value),
})) }))
}} }}
/> />

View File

@ -3,6 +3,11 @@ import { useQuery, useMutation, gql } from '@apollo/client'
import { Input, Loader } from 'semantic-ui-react' import { Input, Loader } from 'semantic-ui-react'
import { InputLabelTitle, InputLabelDescription } from './SettingsPage' import { InputLabelTitle, InputLabelDescription } from './SettingsPage'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { concurrentWorkersQuery } from './__generated__/concurrentWorkersQuery'
import {
setConcurrentWorkers,
setConcurrentWorkersVariables,
} from './__generated__/setConcurrentWorkers'
const CONCURRENT_WORKERS_QUERY = gql` const CONCURRENT_WORKERS_QUERY = gql`
query concurrentWorkersQuery { query concurrentWorkersQuery {
@ -21,21 +26,25 @@ const SET_CONCURRENT_WORKERS_MUTATION = gql`
const ScannerConcurrentWorkers = () => { const ScannerConcurrentWorkers = () => {
const { t } = useTranslation() const { t } = useTranslation()
const workerAmountQuery = useQuery(CONCURRENT_WORKERS_QUERY, { const workerAmountQuery = useQuery<concurrentWorkersQuery>(
CONCURRENT_WORKERS_QUERY,
{
onCompleted(data) { onCompleted(data) {
setWorkerAmount(data.siteInfo.concurrentWorkers) setWorkerAmount(data.siteInfo.concurrentWorkers)
workerAmountServerValue.current = data.siteInfo.concurrentWorkers workerAmountServerValue.current = data.siteInfo.concurrentWorkers
}, },
}) }
const [setWorkersMutation, workersMutationData] = useMutation(
SET_CONCURRENT_WORKERS_MUTATION
) )
const workerAmountServerValue = useRef(null) const [setWorkersMutation, workersMutationData] = useMutation<
const [workerAmount, setWorkerAmount] = useState('') setConcurrentWorkers,
setConcurrentWorkersVariables
>(SET_CONCURRENT_WORKERS_MUTATION)
const updateWorkerAmount = workerAmount => { const workerAmountServerValue = useRef<null | number>(null)
const [workerAmount, setWorkerAmount] = useState(0)
const updateWorkerAmount = (workerAmount: number) => {
if (workerAmountServerValue.current != workerAmount) { if (workerAmountServerValue.current != workerAmount) {
workerAmountServerValue.current = workerAmount workerAmountServerValue.current = workerAmount
setWorkersMutation({ setWorkersMutation({
@ -67,10 +76,10 @@ const ScannerConcurrentWorkers = () => {
id="scanner_concurrent_workers_field" id="scanner_concurrent_workers_field"
value={workerAmount} value={workerAmount}
onChange={(_, { value }) => { onChange={(_, { value }) => {
setWorkerAmount(value) setWorkerAmount(parseInt(value))
}} }}
onBlur={() => updateWorkerAmount(workerAmount)} onBlur={() => updateWorkerAmount(workerAmount)}
onKeyDown={({ key }) => onKeyDown={({ key }: KeyboardEvent) =>
key == 'Enter' && updateWorkerAmount(workerAmount) key == 'Enter' && updateWorkerAmount(workerAmount)
} }
/> />

View File

@ -5,6 +5,7 @@ import PeriodicScanner from './PeriodicScanner'
import ScannerConcurrentWorkers from './ScannerConcurrentWorkers' import ScannerConcurrentWorkers from './ScannerConcurrentWorkers'
import { SectionTitle, InputLabelDescription } from './SettingsPage' import { SectionTitle, InputLabelDescription } from './SettingsPage'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { scanAllMutation } from './__generated__/scanAllMutation'
const SCAN_MUTATION = gql` const SCAN_MUTATION = gql`
mutation scanAllMutation { mutation scanAllMutation {
@ -17,7 +18,7 @@ const SCAN_MUTATION = gql`
const ScannerSection = () => { const ScannerSection = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [startScanner, { called }] = useMutation(SCAN_MUTATION) const [startScanner, { called }] = useMutation<scanAllMutation>(SCAN_MUTATION)
return ( return (
<div> <div>

View File

@ -10,7 +10,7 @@ import ScannerSection from './ScannerSection'
import UserPreferences from './UserPreferences' import UserPreferences from './UserPreferences'
import UsersTable from './Users/UsersTable' import UsersTable from './Users/UsersTable'
export const SectionTitle = styled.h2` export const SectionTitle = styled.h2<{ nospace?: boolean }>`
margin-top: ${({ nospace }) => (nospace ? '0' : '1.4em')} !important; margin-top: ${({ nospace }) => (nospace ? '0' : '1.4em')} !important;
padding-bottom: 0.3em; padding-bottom: 0.3em;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;

View File

@ -1,78 +0,0 @@
import { useMutation, useQuery } from '@apollo/client'
import gql from 'graphql-tag'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Dropdown } from 'semantic-ui-react'
import styled from 'styled-components'
import { SectionTitle } from './SettingsPage'
const languagePreferences = [
{ key: 1, text: 'English', value: 'en' },
{ key: 2, text: 'Dansk', value: 'da' },
]
const CHANGE_USER_PREFERENCES = gql`
mutation($language: String) {
changeUserPreferences(language: $language) {
id
language
}
}
`
const MY_USER_PREFERENCES = gql`
query {
myUserPreferences {
id
language
}
}
`
const UserPreferencesWrapper = styled.div`
margin-bottom: 24px;
`
const UserPreferences = () => {
const { t } = useTranslation()
const { data } = useQuery(MY_USER_PREFERENCES)
const [changePrefs, { loading: loadingPrefs, error }] = useMutation(
CHANGE_USER_PREFERENCES
)
if (error) {
return error.message
}
return (
<UserPreferencesWrapper>
<SectionTitle nospace>
{t('settings.user_preferences.title', 'User preferences')}
</SectionTitle>
<Dropdown
placeholder={t(
'settings.user_preferences.language_selector.placeholder',
'Select language'
)}
clearable
options={languagePreferences}
onChange={(event, { value: language }) => {
changePrefs({
variables: {
language,
},
})
}}
selection
value={data?.myUserPreferences.language}
loading={loadingPrefs}
disabled={loadingPrefs}
/>
</UserPreferencesWrapper>
)
}
export default UserPreferences

View File

@ -0,0 +1,104 @@
import { useMutation, useQuery } from '@apollo/client'
import gql from 'graphql-tag'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Dropdown } from 'semantic-ui-react'
import styled from 'styled-components'
import { LanguageTranslation } from '../../../__generated__/globalTypes'
import {
InputLabelDescription,
InputLabelTitle,
SectionTitle,
} from './SettingsPage'
import {
changeUserPreferences,
changeUserPreferencesVariables,
} from './__generated__/changeUserPreferences'
import { myUserPreferences } from './__generated__/myUserPreferences'
const languagePreferences = [
{ key: 1, text: 'English', flag: 'uk', value: LanguageTranslation.English },
{ key: 2, text: 'Dansk', flag: 'dk', value: LanguageTranslation.Danish },
]
const CHANGE_USER_PREFERENCES = gql`
mutation changeUserPreferences($language: String) {
changeUserPreferences(language: $language) {
id
language
}
}
`
const MY_USER_PREFERENCES = gql`
query myUserPreferences {
myUserPreferences {
id
language
}
}
`
const UserPreferencesWrapper = styled.div`
margin-bottom: 24px;
`
const UserPreferences = () => {
const { t } = useTranslation()
const { data } = useQuery<myUserPreferences>(MY_USER_PREFERENCES)
const [changePrefs, { loading: loadingPrefs, error }] = useMutation<
changeUserPreferences,
changeUserPreferencesVariables
>(CHANGE_USER_PREFERENCES)
if (error) {
return <div>{error.message}</div>
}
return (
<UserPreferencesWrapper>
<SectionTitle nospace>
{t('settings.user_preferences.title', 'User preferences')}
</SectionTitle>
<label id="user_pref_change_language_field">
<InputLabelTitle>
{t(
'settings.user_preferences.change_language.label',
'Website language'
)}
</InputLabelTitle>
<InputLabelDescription>
{t(
'settings.user_preferences.change_language.description',
'Change website language specific for this user'
)}
</InputLabelDescription>
</label>
<Dropdown
id="user_pref_change_language_field"
placeholder={t(
'settings.user_preferences.language_selector.placeholder',
'Select language'
)}
clearable
options={languagePreferences}
onChange={(event, { value: language }) => {
changePrefs({
variables: {
language: language as LanguageTranslation,
},
})
}}
selection
search
value={data?.myUserPreferences.language || undefined}
loading={loadingPrefs}
disabled={loadingPrefs}
/>
</UserPreferencesWrapper>
)
}
export default UserPreferences

View File

@ -1,10 +1,9 @@
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button, Checkbox, Input, Table } from 'semantic-ui-react' import { Button, Checkbox, Input, Table } from 'semantic-ui-react'
const createUserMutation = gql` const CREATE_USER_MUTATION = gql`
mutation createUser($username: String!, $admin: Boolean!) { mutation createUser($username: String!, $admin: Boolean!) {
createUser(username: $username, admin: $admin) { createUser(username: $username, admin: $admin) {
id id
@ -15,7 +14,7 @@ const createUserMutation = gql`
} }
` `
const addRootPathMutation = gql` export const USER_ADD_ROOT_PATH_MUTATION = gql`
mutation userAddRootPath($id: ID!, $rootPath: String!) { mutation userAddRootPath($id: ID!, $rootPath: String!) {
userAddRootPath(id: $id, rootPath: $rootPath) { userAddRootPath(id: $id, rootPath: $rootPath) {
id id
@ -30,12 +29,18 @@ const initialState = {
userAdded: false, userAdded: false,
} }
const AddUserRow = ({ setShow, show, onUserAdded }) => { type AddUserRowProps = {
setShow: React.Dispatch<React.SetStateAction<boolean>>
show: boolean
onUserAdded(): void
}
const AddUserRow = ({ setShow, show, onUserAdded }: AddUserRowProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [state, setState] = useState(initialState) const [state, setState] = useState(initialState)
const [addRootPath, { loading: addRootPathLoading }] = useMutation( const [addRootPath, { loading: addRootPathLoading }] = useMutation(
addRootPathMutation, USER_ADD_ROOT_PATH_MUTATION,
{ {
onCompleted: () => { onCompleted: () => {
setState(initialState) setState(initialState)
@ -49,7 +54,7 @@ const AddUserRow = ({ setShow, show, onUserAdded }) => {
) )
const [createUser, { loading: createUserLoading }] = useMutation( const [createUser, { loading: createUserLoading }] = useMutation(
createUserMutation, CREATE_USER_MUTATION,
{ {
onCompleted: ({ createUser: { id } }) => { onCompleted: ({ createUser: { id } }) => {
if (state.rootPath) { if (state.rootPath) {
@ -68,7 +73,10 @@ const AddUserRow = ({ setShow, show, onUserAdded }) => {
const loading = addRootPathLoading || createUserLoading const loading = addRootPathLoading || createUserLoading
function updateInput(event, key) { function updateInput(
event: React.ChangeEvent<HTMLInputElement>,
key: string
) {
setState({ setState({
...state, ...state,
[key]: event.target.value, [key]: event.target.value,
@ -105,7 +113,7 @@ const AddUserRow = ({ setShow, show, onUserAdded }) => {
onChange={(e, data) => { onChange={(e, data) => {
setState({ setState({
...state, ...state,
admin: data.checked, admin: data.checked || false,
}) })
}} }}
/> />
@ -137,10 +145,4 @@ const AddUserRow = ({ setShow, show, onUserAdded }) => {
) )
} }
AddUserRow.propTypes = {
setShow: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
onUserAdded: PropTypes.func.isRequired,
}
export default AddUserRow export default AddUserRow

View File

@ -2,7 +2,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button, Checkbox, Input, Table } from 'semantic-ui-react' import { Button, Checkbox, Input, Table } from 'semantic-ui-react'
import { EditRootPaths } from './EditUserRowRootPaths' import { EditRootPaths } from './EditUserRowRootPaths'
import { UserRowProps } from './UserRow' import { UserRowProps, UserRowChildProps } from './UserRow'
const EditUserRow = ({ const EditUserRow = ({
user, user,
@ -10,9 +10,13 @@ const EditUserRow = ({
setState, setState,
updateUser, updateUser,
updateUserLoading, updateUserLoading,
}) => { }: UserRowChildProps) => {
const { t } = useTranslation() const { t } = useTranslation()
function updateInput(event, key) {
function updateInput(
event: React.ChangeEvent<HTMLInputElement>,
key: string
) {
setState(state => ({ setState(state => ({
...state, ...state,
[key]: event.target.value, [key]: event.target.value,
@ -39,7 +43,7 @@ const EditUserRow = ({
onChange={(_, data) => { onChange={(_, data) => {
setState(state => ({ setState(state => ({
...state, ...state,
admin: data.checked, admin: data.checked || false,
})) }))
}} }}
/> />
@ -50,6 +54,7 @@ const EditUserRow = ({
negative negative
onClick={() => onClick={() =>
setState(state => ({ setState(state => ({
...state,
...state.oldState, ...state.oldState,
})) }))
} }

View File

@ -1,20 +1,21 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react' import React, { useState } from 'react'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { Button, Icon, Input } from 'semantic-ui-react' import { Button, Icon, Input } from 'semantic-ui-react'
import styled from 'styled-components' import styled from 'styled-components'
import { USERS_QUERY } from './UsersTable' import { USERS_QUERY } from './UsersTable'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { USER_ADD_ROOT_PATH_MUTATION } from './AddUserRow'
import {
userRemoveAlbumPathMutation,
userRemoveAlbumPathMutationVariables,
} from './__generated__/userRemoveAlbumPathMutation'
import {
settingsUsersQuery_user,
settingsUsersQuery_user_rootAlbums,
} from './__generated__/settingsUsersQuery'
import { userAddRootPath } from './__generated__/userAddRootPath'
const userAddRootPathMutation = gql` const USER_REMOVE_ALBUM_PATH_MUTATION = gql`
mutation userAddRootPath($id: ID!, $rootPath: String!) {
userAddRootPath(id: $id, rootPath: $rootPath) {
id
}
}
`
const userRemoveAlbumPathMutation = gql`
mutation userRemoveAlbumPathMutation($userId: ID!, $albumId: ID!) { mutation userRemoveAlbumPathMutation($userId: ID!, $albumId: ID!) {
userRemoveRootAlbum(userId: $userId, albumId: $albumId) { userRemoveRootAlbum(userId: $userId, albumId: $albumId) {
id id
@ -28,18 +29,23 @@ const RootPathListItem = styled.li`
align-items: center; align-items: center;
` `
const EditRootPath = ({ album, user }) => { type EditRootPathProps = {
album: settingsUsersQuery_user_rootAlbums
user: settingsUsersQuery_user
}
const EditRootPath = ({ album, user }: EditRootPathProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [removeAlbumPath, { loading }] = useMutation( const [removeAlbumPath, { loading }] = useMutation<
userRemoveAlbumPathMutation, userRemoveAlbumPathMutation,
{ userRemoveAlbumPathMutationVariables
>(USER_REMOVE_ALBUM_PATH_MUTATION, {
refetchQueries: [ refetchQueries: [
{ {
query: USERS_QUERY, query: USERS_QUERY,
}, },
], ],
} })
)
return ( return (
<RootPathListItem> <RootPathListItem>
@ -63,33 +69,37 @@ const EditRootPath = ({ album, user }) => {
) )
} }
EditRootPath.propTypes = {
album: PropTypes.object.isRequired,
user: PropTypes.object.isRequired,
}
const NewRootPathInput = styled(Input)` const NewRootPathInput = styled(Input)`
width: 100%; width: 100%;
margin-top: 24px; margin-top: 24px;
` `
const EditNewRootPath = ({ userID }) => { type EditNewRootPathProps = {
userID: string
}
const EditNewRootPath = ({ userID }: EditNewRootPathProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [addRootPath, { loading }] = useMutation(userAddRootPathMutation, { const [addRootPath, { loading }] = useMutation<userAddRootPath>(
USER_ADD_ROOT_PATH_MUTATION,
{
refetchQueries: [ refetchQueries: [
{ {
query: USERS_QUERY, query: USERS_QUERY,
}, },
], ],
}) }
)
return ( return (
<li> <li>
<NewRootPathInput <NewRootPathInput
style={{ width: '100%' }} style={{ width: '100%' }}
value={value} value={value}
onChange={e => setValue(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue(e.target.value)
}
disabled={loading} disabled={loading}
action={{ action={{
positive: true, positive: true,
@ -110,17 +120,17 @@ const EditNewRootPath = ({ userID }) => {
) )
} }
EditNewRootPath.propTypes = {
userID: PropTypes.string.isRequired,
}
const RootPathList = styled.ul` const RootPathList = styled.ul`
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
` `
export const EditRootPaths = ({ user }) => { type EditRootPathsProps = {
user: settingsUsersQuery_user
}
export const EditRootPaths = ({ user }: EditRootPathsProps) => {
const editRows = user.rootAlbums.map(album => ( const editRows = user.rootAlbums.map(album => (
<EditRootPath key={album.id} album={album} user={user} /> <EditRootPath key={album.id} album={album} user={user} />
)) ))
@ -132,7 +142,3 @@ export const EditRootPaths = ({ user }) => {
</RootPathList> </RootPathList>
) )
} }
EditRootPaths.propTypes = {
user: PropTypes.object.isRequired,
}

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { Button, Form, Input, Modal } from 'semantic-ui-react' import { Button, Form, Input, Modal, ModalProps } from 'semantic-ui-react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { settingsUsersQuery_user } from './__generated__/settingsUsersQuery'
const changeUserPasswordMutation = gql` const changeUserPasswordMutation = gql`
mutation changeUserPassword($userId: ID!, $password: String!) { mutation changeUserPassword($userId: ID!, $password: String!) {
@ -12,7 +12,18 @@ const changeUserPasswordMutation = gql`
} }
` `
const ChangePasswordModal = ({ onClose, user, ...props }) => { interface ChangePasswordModalProps extends ModalProps {
onClose(): void
open: boolean
user: settingsUsersQuery_user
}
const ChangePasswordModal = ({
onClose,
user,
open,
...props
}: ChangePasswordModalProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [passwordInput, setPasswordInput] = useState('') const [passwordInput, setPasswordInput] = useState('')
@ -23,7 +34,7 @@ const ChangePasswordModal = ({ onClose, user, ...props }) => {
}) })
return ( return (
<Modal {...props}> <Modal open={open} {...props}>
<Modal.Header> <Modal.Header>
{t('settings.users.password_reset.title', 'Change password')} {t('settings.users.password_reset.title', 'Change password')}
</Modal.Header> </Modal.Header>
@ -71,9 +82,4 @@ const ChangePasswordModal = ({ onClose, user, ...props }) => {
) )
} }
ChangePasswordModal.propTypes = {
onClose: PropTypes.func,
user: PropTypes.object.isRequired,
}
export default ChangePasswordModal export default ChangePasswordModal

View File

@ -1,111 +0,0 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { gql, useMutation } from '@apollo/client'
import EditUserRow from './EditUserRow'
import ViewUserRow from './ViewUserRow'
const updateUserMutation = gql`
mutation updateUser($id: ID!, $username: String, $admin: Boolean) {
updateUser(id: $id, username: $username, admin: $admin) {
id
username
admin
}
}
`
const deleteUserMutation = gql`
mutation deleteUser($id: ID!) {
deleteUser(id: $id) {
id
username
}
}
`
const scanUserMutation = gql`
mutation scanUser($userId: ID!) {
scanUser(userId: $userId) {
success
}
}
`
const UserRow = ({ user, refetchUsers }) => {
const [state, setState] = useState({
...user,
editing: false,
newRootPath: '',
})
const [showConfirmDelete, setConfirmDelete] = useState(false)
const [showChangePassword, setChangePassword] = useState(false)
const [updateUser, { loading: updateUserLoading }] = useMutation(
updateUserMutation,
{
onCompleted: data => {
setState({
...data.updateUser,
editing: false,
})
refetchUsers()
},
}
)
const [deleteUser] = useMutation(deleteUserMutation, {
onCompleted: () => {
refetchUsers()
},
})
const [scanUser, { called: scanUserCalled }] = useMutation(scanUserMutation, {
onCompleted: () => {
refetchUsers()
},
})
const props = {
user,
state,
setState,
scanUser,
updateUser,
updateUserLoading,
deleteUser,
setChangePassword,
setConfirmDelete,
scanUserCalled,
showChangePassword,
showConfirmDelete,
}
if (state.editing) {
return <EditUserRow {...props} />
}
return <ViewUserRow {...props} />
}
UserRow.propTypes = {
user: PropTypes.object.isRequired,
refetchUsers: PropTypes.func.isRequired,
}
export const UserRowProps = {
user: PropTypes.object.isRequired,
state: PropTypes.object.isRequired,
setState: PropTypes.func.isRequired,
scanUser: PropTypes.func.isRequired,
updateUser: PropTypes.func.isRequired,
updateUserLoading: PropTypes.bool.isRequired,
deleteUser: PropTypes.func.isRequired,
setChangePassword: PropTypes.func.isRequired,
setConfirmDelete: PropTypes.func.isRequired,
scanUserCalled: PropTypes.func.isRequired,
showChangePassword: PropTypes.func.isRequired,
showConfirmDelete: PropTypes.func.isRequired,
}
export default UserRow

View File

@ -0,0 +1,153 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import {
FetchResult,
gql,
MutationFunctionOptions,
useMutation,
} from '@apollo/client'
import EditUserRow from './EditUserRow'
import ViewUserRow from './ViewUserRow'
import { settingsUsersQuery_user } from './__generated__/settingsUsersQuery'
import { scanUser, scanUserVariables } from './__generated__/scanUser'
import { updateUser, updateUserVariables } from './__generated__/updateUser'
import { deleteUser, deleteUserVariables } from './__generated__/deleteUser'
const updateUserMutation = gql`
mutation updateUser($id: ID!, $username: String, $admin: Boolean) {
updateUser(id: $id, username: $username, admin: $admin) {
id
username
admin
}
}
`
const deleteUserMutation = gql`
mutation deleteUser($id: ID!) {
deleteUser(id: $id) {
id
username
}
}
`
const scanUserMutation = gql`
mutation scanUser($userId: ID!) {
scanUser(userId: $userId) {
success
}
}
`
export const UserRowProps = {
user: PropTypes.object.isRequired,
state: PropTypes.object.isRequired,
setState: PropTypes.func.isRequired,
scanUser: PropTypes.func.isRequired,
updateUser: PropTypes.func.isRequired,
updateUserLoading: PropTypes.bool.isRequired,
deleteUser: PropTypes.func.isRequired,
setChangePassword: PropTypes.func.isRequired,
setConfirmDelete: PropTypes.func.isRequired,
scanUserCalled: PropTypes.func.isRequired,
showChangePassword: PropTypes.func.isRequired,
showConfirmDelete: PropTypes.func.isRequired,
}
interface UserRowState extends settingsUsersQuery_user {
editing: boolean
newRootPath: string
oldState?: Omit<UserRowState, 'oldState'>
}
type ApolloMutationFn<MutationType, VariablesType> = (
options?: MutationFunctionOptions<MutationType, VariablesType>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<FetchResult<MutationType, any, any>>
export type UserRowChildProps = {
user: settingsUsersQuery_user
state: UserRowState
setState: React.Dispatch<React.SetStateAction<UserRowState>>
scanUser: ApolloMutationFn<scanUser, scanUserVariables>
updateUser: ApolloMutationFn<updateUser, updateUserVariables>
updateUserLoading: boolean
deleteUser: ApolloMutationFn<deleteUser, deleteUserVariables>
setChangePassword: React.Dispatch<React.SetStateAction<boolean>>
setConfirmDelete: React.Dispatch<React.SetStateAction<boolean>>
scanUserCalled: boolean
showChangePassword: boolean
showConfirmDelete: boolean
}
type UserRowProps = {
user: settingsUsersQuery_user
refetchUsers(): void
}
const UserRow = ({ user, refetchUsers }: UserRowProps) => {
const [state, setState] = useState<UserRowState>({
...user,
editing: false,
newRootPath: '',
})
const [showConfirmDelete, setConfirmDelete] = useState(false)
const [showChangePassword, setChangePassword] = useState(false)
const [updateUser, { loading: updateUserLoading }] = useMutation<
updateUser,
updateUserVariables
>(updateUserMutation, {
onCompleted: data => {
setState(state => ({
...state,
...data.updateUser,
editing: false,
}))
refetchUsers()
},
})
const [deleteUser] = useMutation<deleteUser, deleteUserVariables>(
deleteUserMutation,
{
onCompleted: () => {
refetchUsers()
},
}
)
const [scanUser, { called: scanUserCalled }] = useMutation<
scanUser,
scanUserVariables
>(scanUserMutation, {
onCompleted: () => {
refetchUsers()
},
})
const props: UserRowChildProps = {
user,
state,
setState,
scanUser,
updateUser,
updateUserLoading,
deleteUser,
setChangePassword,
setConfirmDelete,
scanUserCalled,
showChangePassword,
showConfirmDelete,
}
if (state.editing) {
return <EditUserRow {...props} />
}
return <ViewUserRow {...props} />
}
export default UserRow

View File

@ -6,13 +6,13 @@ import UserRow from './UserRow'
import AddUserRow from './AddUserRow' import AddUserRow from './AddUserRow'
import { SectionTitle } from '../SettingsPage' import { SectionTitle } from '../SettingsPage'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { settingsUsersQuery } from './__generated__/settingsUsersQuery'
export const USERS_QUERY = gql` export const USERS_QUERY = gql`
query settingsUsersQuery { query settingsUsersQuery {
user { user {
id id
username username
# rootPath
admin admin
rootAlbums { rootAlbums {
id id
@ -26,14 +26,16 @@ const UsersTable = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [showAddUser, setShowAddUser] = useState(false) const [showAddUser, setShowAddUser] = useState(false)
const { loading, error, data, refetch } = useQuery(USERS_QUERY) const { loading, error, data, refetch } = useQuery<settingsUsersQuery>(
USERS_QUERY
)
if (error) { if (error) {
return `Users table error: ${error.message}` return <div>{`Users table error: ${error.message}`}</div>
} }
let userRows = [] let userRows: JSX.Element[] = []
if (data && data.user) { if (data?.user) {
userRows = data.user.map(user => ( userRows = data.user.map(user => (
<UserRow user={user} refetchUsers={refetch} key={user.id} /> <UserRow user={user} refetchUsers={refetch} key={user.id} />
)) ))

View File

@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'
import { Button, Icon, Table, Modal } from 'semantic-ui-react' import { Button, Icon, Table, Modal } from 'semantic-ui-react'
import styled from 'styled-components' import styled from 'styled-components'
import ChangePasswordModal from './UserChangePassword' import ChangePasswordModal from './UserChangePassword'
import { UserRowProps } from './UserRow' import { UserRowChildProps, UserRowProps } from './UserRow'
const PathList = styled.ul` const PathList = styled.ul`
margin: 0; margin: 0;
@ -22,7 +22,7 @@ const ViewUserRow = ({
scanUserCalled, scanUserCalled,
showChangePassword, showChangePassword,
showConfirmDelete, showConfirmDelete,
}) => { }: UserRowChildProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const paths = ( const paths = (
<PathList> <PathList>
@ -43,7 +43,11 @@ const ViewUserRow = ({
<Button.Group> <Button.Group>
<Button <Button
onClick={() => { onClick={() => {
setState(state => ({ ...state, editing: true, oldState: state })) setState(state => {
const oldState = { ...state }
delete oldState.oldState
return { ...state, editing: true, oldState }
})
}} }}
> >
<Icon name="edit" /> <Icon name="edit" />

View File

@ -0,0 +1,22 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: changeUserPassword
// ====================================================
export interface changeUserPassword_updateUser {
__typename: 'User'
id: string
}
export interface changeUserPassword {
updateUser: changeUserPassword_updateUser
}
export interface changeUserPasswordVariables {
userId: string
password: string
}

View File

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: createUser
// ====================================================
export interface createUser_createUser {
__typename: 'User'
id: string
username: string
admin: boolean
}
export interface createUser {
createUser: createUser_createUser
}
export interface createUserVariables {
username: string
admin: boolean
}

View File

@ -0,0 +1,22 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: deleteUser
// ====================================================
export interface deleteUser_deleteUser {
__typename: 'User'
id: string
username: string
}
export interface deleteUser {
deleteUser: deleteUser_deleteUser
}
export interface deleteUserVariables {
id: string
}

View File

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: scanUser
// ====================================================
export interface scanUser_scanUser {
__typename: "ScannerResult";
success: boolean;
}
export interface scanUser {
/**
* Scan a single user for new media
*/
scanUser: scanUser_scanUser;
}
export interface scanUserVariables {
userId: string;
}

View File

@ -0,0 +1,35 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: settingsUsersQuery
// ====================================================
export interface settingsUsersQuery_user_rootAlbums {
__typename: "Album";
id: string;
/**
* The path on the filesystem of the server, where this album is located
*/
filePath: string;
}
export interface settingsUsersQuery_user {
__typename: "User";
id: string;
username: string;
admin: boolean;
/**
* Top level albums owned by this user
*/
rootAlbums: settingsUsersQuery_user_rootAlbums[];
}
export interface settingsUsersQuery {
/**
* List of registered users, must be admin to call
*/
user: settingsUsersQuery_user[];
}

View File

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: updateUser
// ====================================================
export interface updateUser_updateUser {
__typename: 'User'
id: string
username: string
admin: boolean
}
export interface updateUser {
updateUser: updateUser_updateUser
}
export interface updateUserVariables {
id: string
username?: string | null
admin?: boolean | null
}

View File

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: userAddRootPath
// ====================================================
export interface userAddRootPath_userAddRootPath {
__typename: "Album";
id: string;
}
export interface userAddRootPath {
/**
* Add a root path from where to look for media for the given user
*/
userAddRootPath: userAddRootPath_userAddRootPath | null;
}
export interface userAddRootPathVariables {
id: string;
rootPath: string;
}

View File

@ -0,0 +1,22 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: userRemoveAlbumPathMutation
// ====================================================
export interface userRemoveAlbumPathMutation_userRemoveRootAlbum {
__typename: "Album";
id: string;
}
export interface userRemoveAlbumPathMutation {
userRemoveRootAlbum: userRemoveAlbumPathMutation_userRemoveRootAlbum | null;
}
export interface userRemoveAlbumPathMutationVariables {
userId: string;
albumId: string;
}

View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: changeScanIntervalMutation
// ====================================================
export interface changeScanIntervalMutation {
/**
* Set how often, in seconds, the server should automatically scan for new media,
* a value of 0 will disable periodic scans
*/
setPeriodicScanInterval: number;
}
export interface changeScanIntervalMutationVariables {
interval: number;
}

View File

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { LanguageTranslation } from "./../../../../__generated__/globalTypes";
// ====================================================
// GraphQL mutation operation: changeUserPreferences
// ====================================================
export interface changeUserPreferences_changeUserPreferences {
__typename: "UserPreferences";
id: string;
language: LanguageTranslation | null;
}
export interface changeUserPreferences {
changeUserPreferences: changeUserPreferences_changeUserPreferences;
}
export interface changeUserPreferencesVariables {
language?: string | null;
}

View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: concurrentWorkersQuery
// ====================================================
export interface concurrentWorkersQuery_siteInfo {
__typename: "SiteInfo";
/**
* How many max concurrent scanner jobs that should run at once
*/
concurrentWorkers: number;
}
export interface concurrentWorkersQuery {
siteInfo: concurrentWorkersQuery_siteInfo;
}

View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { LanguageTranslation } from "./../../../../__generated__/globalTypes";
// ====================================================
// GraphQL query operation: myUserPreferences
// ====================================================
export interface myUserPreferences_myUserPreferences {
__typename: "UserPreferences";
id: string;
language: LanguageTranslation | null;
}
export interface myUserPreferences {
myUserPreferences: myUserPreferences_myUserPreferences;
}

View File

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: scanAllMutation
// ====================================================
export interface scanAllMutation_scanAll {
__typename: "ScannerResult";
success: boolean;
message: string | null;
}
export interface scanAllMutation {
/**
* Scan all users for new media
*/
scanAll: scanAllMutation_scanAll;
}

View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: scanIntervalQuery
// ====================================================
export interface scanIntervalQuery_siteInfo {
__typename: "SiteInfo";
/**
* How often automatic scans should be initiated in seconds
*/
periodicScanInterval: number;
}
export interface scanIntervalQuery {
siteInfo: scanIntervalQuery_siteInfo;
}

View File

@ -0,0 +1,19 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: setConcurrentWorkers
// ====================================================
export interface setConcurrentWorkers {
/**
* Set max number of concurrent scanner jobs running at once
*/
setScannerConcurrentWorkers: number;
}
export interface setConcurrentWorkersVariables {
workers: number;
}

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import Layout from '../../Layout' import Layout from '../../Layout'
import AlbumGallery from '../../components/albumGallery/AlbumGallery' import AlbumGallery from '../../components/albumGallery/AlbumGallery'
@ -73,7 +72,13 @@ const AlbumSharePageWrapper = styled.div`
height: 100%; height: 100%;
` `
const AlbumSharePage = ({ albumID, token, password }) => { type AlbumSharePageProps = {
albumID: string
token: string
password: string | null
}
const AlbumSharePage = ({ albumID, token, password }: AlbumSharePageProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data, loading, error } = useQuery(SHARE_ALBUM_QUERY, { const { data, loading, error } = useQuery(SHARE_ALBUM_QUERY, {
variables: { variables: {
@ -86,11 +91,11 @@ const AlbumSharePage = ({ albumID, token, password }) => {
}) })
if (error) { if (error) {
return error.message return <div>{error.message}</div>
} }
if (loading) { if (loading) {
return t('general.loading.default', 'Loading...') return <div>{t('general.loading.default', 'Loading...')}</div>
} }
const album = data.album const album = data.album
@ -111,10 +116,4 @@ const AlbumSharePage = ({ albumID, token, password }) => {
) )
} }
AlbumSharePage.propTypes = {
albumID: PropTypes.string.isRequired,
token: PropTypes.string.isRequired,
password: PropTypes.string,
}
export default AlbumSharePage export default AlbumSharePage

View File

@ -1,58 +0,0 @@
import React, { useContext, useEffect } from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import Layout from '../../Layout'
import {
ProtectedImage,
ProtectedVideo,
} from '../../components/photoGallery/ProtectedMedia'
import { SidebarContext } from '../../components/sidebar/Sidebar'
import MediaSidebar from '../../components/sidebar/MediaSidebar'
const DisplayPhoto = styled(ProtectedImage)`
width: 100%;
max-height: calc(80vh);
object-fit: contain;
`
const DisplayVideo = styled(ProtectedVideo)`
width: 100%;
max-height: calc(80vh);
`
const MediaView = ({ media }) => {
const { updateSidebar } = useContext(SidebarContext)
useEffect(() => {
updateSidebar(<MediaSidebar media={media} hidePreview />)
}, [media])
if (media.type == 'photo') {
return <DisplayPhoto src={media.highRes.url} />
}
if (media.type == 'video') {
return <DisplayVideo media={media} />
}
throw new Error(`Unsupported media type: ${media.type}`)
}
MediaView.propTypes = {
media: PropTypes.object.isRequired,
}
const MediaSharePage = ({ media }) => (
<Layout>
<div data-testid="MediaSharePage">
<h1>{media.title}</h1>
<MediaView media={media} />
</div>
</Layout>
)
MediaSharePage.propTypes = {
media: PropTypes.object.isRequired,
}
export default MediaSharePage

View File

@ -0,0 +1,61 @@
import React, { useContext, useEffect } from 'react'
import styled from 'styled-components'
import Layout from '../../Layout'
import {
ProtectedImage,
ProtectedVideo,
} from '../../components/photoGallery/ProtectedMedia'
import { SidebarContext } from '../../components/sidebar/Sidebar'
import MediaSidebar from '../../components/sidebar/MediaSidebar'
import { useTranslation } from 'react-i18next'
import { SharePageToken_shareToken_media } from './__generated__/SharePageToken'
import { MediaType } from '../../../__generated__/globalTypes'
const DisplayPhoto = styled(ProtectedImage)`
width: 100%;
max-height: calc(80vh);
object-fit: contain;
`
const DisplayVideo = styled(ProtectedVideo)`
width: 100%;
max-height: calc(80vh);
`
type MediaViewProps = {
media: SharePageToken_shareToken_media
}
const MediaView = ({ media }: MediaViewProps) => {
const { updateSidebar } = useContext(SidebarContext)
useEffect(() => {
updateSidebar(<MediaSidebar media={media} hidePreview />)
}, [media])
switch (media.type) {
case MediaType.Photo:
return <DisplayPhoto src={media.highRes?.url} />
case MediaType.Video:
return <DisplayVideo media={media} />
}
}
type MediaSharePageType = {
media: SharePageToken_shareToken_media
}
const MediaSharePage = ({ media }: MediaSharePageType) => {
const { t } = useTranslation()
return (
<Layout title={t('share_page.media.title', 'Shared media')}>
<div data-testid="MediaSharePage">
<h1>{media.title}</h1>
<MediaView media={media} />
</div>
</Layout>
)
}
export default MediaSharePage

View File

@ -82,7 +82,7 @@ describe('load correct share page, based on graphql query', () => {
media: { media: {
id: '1', id: '1',
title: 'shared_image.jpg', title: 'shared_image.jpg',
type: 'photo', type: 'Photo',
highRes: { highRes: {
url: 'https://example.com/shared_image.jpg', url: 'https://example.com/shared_image.jpg',
}, },

View File

@ -1,8 +1,7 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React, { useState } from 'react' import React, { useState } from 'react'
import { useQuery, gql } from '@apollo/client' import { useQuery, gql } from '@apollo/client'
import { Route, Switch } from 'react-router-dom' import { match as MatchType, Route, Switch } from 'react-router-dom'
import RouterProps from 'react-router-prop-types'
import { Form, Header, Icon, Input, Message } from 'semantic-ui-react' import { Form, Header, Icon, Input, Message } from 'semantic-ui-react'
import styled from 'styled-components' import styled from 'styled-components'
import { import {
@ -45,8 +44,11 @@ export const SHARE_TOKEN_QUERY = gql`
} }
videoWeb { videoWeb {
url url
width
height
} }
exif { exif {
id
camera camera
maker maker
lens lens
@ -71,7 +73,7 @@ export const VALIDATE_TOKEN_PASSWORD_QUERY = gql`
} }
` `
const AuthorizedTokenRoute = ({ match }) => { const AuthorizedTokenRoute = ({ match }: MatchProps<TokenRouteMatch>) => {
const { t } = useTranslation() const { t } = useTranslation()
const token = match.params.token const token = match.params.token
@ -84,11 +86,11 @@ const AuthorizedTokenRoute = ({ match }) => {
}, },
}) })
if (error) return error.message if (error) return <div>{error.message}</div>
if (loading) return 'Loading...' if (loading) return <div>{t('general.loading.default', 'Loading...')}</div>
if (data.shareToken.album) { if (data.shareToken.album) {
const SharedSubAlbumPage = ({ match }) => { const SharedSubAlbumPage = ({ match }: MatchProps<SubalbumRouteMatch>) => {
return ( return (
<AlbumSharePage <AlbumSharePage
albumID={match.params.subAlbum} albumID={match.params.subAlbum}
@ -136,10 +138,15 @@ const MessageContainer = styled.div`
margin: 100px auto 0; margin: 100px auto 0;
` `
type ProtectedTokenEnterPasswordProps = {
refetchWithPassword(password: string): void
loading: boolean
}
const ProtectedTokenEnterPassword = ({ const ProtectedTokenEnterPassword = ({
refetchWithPassword, refetchWithPassword,
loading = false, loading = false,
}) => { }: ProtectedTokenEnterPasswordProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [passwordValue, setPasswordValue] = useState('') const [passwordValue, setPasswordValue] = useState('')
@ -178,7 +185,9 @@ const ProtectedTokenEnterPassword = ({
<Input <Input
loading={loading} loading={loading}
disabled={loading} disabled={loading}
onKeyUp={event => event.key == 'Enter' && onSubmit()} onKeyUp={(event: KeyboardEvent) =>
event.key == 'Enter' && onSubmit()
}
onChange={e => setPasswordValue(e.target.value)} onChange={e => setPasswordValue(e.target.value)}
placeholder={t('login_page.field.password', 'Password')} placeholder={t('login_page.field.password', 'Password')}
type="password" type="password"
@ -191,12 +200,19 @@ const ProtectedTokenEnterPassword = ({
) )
} }
ProtectedTokenEnterPassword.propTypes = { interface TokenRouteMatch {
refetchWithPassword: PropTypes.func.isRequired, token: string
loading: PropTypes.bool,
} }
const TokenRoute = ({ match }) => { interface SubalbumRouteMatch extends TokenRouteMatch {
subAlbum: string
}
interface MatchProps<Route> {
match: MatchType<Route>
}
const TokenRoute = ({ match }: MatchProps<TokenRouteMatch>) => {
const { t } = useTranslation() const { t } = useTranslation()
const token = match.params.token const token = match.params.token
@ -227,13 +243,12 @@ const TokenRoute = ({ match }) => {
) )
} }
return error.message return <div>{error.message}</div>
} }
if (data && data.shareTokenValidatePassword == false) { if (data && data.shareTokenValidatePassword == false) {
return ( return (
<ProtectedTokenEnterPassword <ProtectedTokenEnterPassword
match={match}
refetchWithPassword={password => { refetchWithPassword={password => {
saveSharePassword(token, password) saveSharePassword(token, password)
refetch({ token, password }) refetch({ token, password })
@ -243,22 +258,18 @@ const TokenRoute = ({ match }) => {
) )
} }
if (loading) return t('general.loading.default', 'Loading...') if (loading) return <div>{t('general.loading.default', 'Loading...')}</div>
return <AuthorizedTokenRoute match={match} /> return <AuthorizedTokenRoute match={match} />
} }
TokenRoute.propTypes = { const SharePage = ({ match }: { match: MatchType }) => {
match: PropTypes.object.isRequired,
}
const SharePage = ({ match }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Switch> <Switch>
<Route path={`${match.url}/:token`}> <Route path={`${match.url}/:token`}>
{({ match }) => { {({ match }: { match: MatchType<TokenRouteMatch> }) => {
return <TokenRoute match={match} /> return <TokenRoute match={match} />
}} }}
</Route> </Route>
@ -267,8 +278,4 @@ const SharePage = ({ match }) => {
) )
} }
SharePage.propTypes = {
...RouterProps,
}
export default SharePage export default SharePage

View File

@ -0,0 +1,174 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MediaType } from './../../../../__generated__/globalTypes'
// ====================================================
// GraphQL query operation: SharePageToken
// ====================================================
export interface SharePageToken_shareToken_album {
__typename: 'Album'
id: string
}
export interface SharePageToken_shareToken_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 SharePageToken_shareToken_media_downloads_mediaUrl {
__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
/**
* The file size of the resource in bytes
*/
fileSize: number
}
export interface SharePageToken_shareToken_media_downloads {
__typename: 'MediaDownload'
title: string
mediaUrl: SharePageToken_shareToken_media_downloads_mediaUrl
}
export interface SharePageToken_shareToken_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 SharePageToken_shareToken_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 SharePageToken_shareToken_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 SharePageToken_shareToken_media {
__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[]
/**
* URL to display the photo in full resolution, will be null for videos
*/
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
}
export interface SharePageToken_shareToken {
__typename: 'ShareToken'
token: string
/**
* The album this token shares
*/
album: SharePageToken_shareToken_album | null
/**
* The media this token shares
*/
media: SharePageToken_shareToken_media | null
}
export interface SharePageToken {
shareToken: SharePageToken_shareToken
}
export interface SharePageTokenVariables {
token: string
password?: string | null
}

View File

@ -0,0 +1,17 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: ShareTokenValidatePassword
// ====================================================
export interface ShareTokenValidatePassword {
shareTokenValidatePassword: boolean;
}
export interface ShareTokenValidatePasswordVariables {
token: string;
password?: string | null;
}

View File

@ -0,0 +1,194 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MediaType } from "./../../../../__generated__/globalTypes";
// ====================================================
// GraphQL query operation: shareAlbumQuery
// ====================================================
export interface shareAlbumQuery_album_subAlbums_thumbnail_thumbnail {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface shareAlbumQuery_album_subAlbums_thumbnail {
__typename: "Media";
/**
* URL to display the media in a smaller resolution
*/
thumbnail: shareAlbumQuery_album_subAlbums_thumbnail_thumbnail | null;
}
export interface shareAlbumQuery_album_subAlbums {
__typename: "Album";
id: string;
title: string;
/**
* An image in this album used for previewing this album
*/
thumbnail: shareAlbumQuery_album_subAlbums_thumbnail | null;
}
export interface shareAlbumQuery_album_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 shareAlbumQuery_album_media_downloads_mediaUrl {
__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;
/**
* The file size of the resource in bytes
*/
fileSize: number;
}
export interface shareAlbumQuery_album_media_downloads {
__typename: "MediaDownload";
title: string;
mediaUrl: shareAlbumQuery_album_media_downloads_mediaUrl;
}
export interface shareAlbumQuery_album_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 shareAlbumQuery_album_media_videoWeb {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface shareAlbumQuery_album_media_exif {
__typename: "MediaEXIF";
/**
* 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 shareAlbumQuery_album_media {
__typename: "Media";
id: string;
title: string;
type: MediaType;
/**
* URL to display the media in a smaller resolution
*/
thumbnail: shareAlbumQuery_album_media_thumbnail | null;
downloads: shareAlbumQuery_album_media_downloads[];
/**
* URL to display the photo in full resolution, will be null for videos
*/
highRes: shareAlbumQuery_album_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: shareAlbumQuery_album_media_videoWeb | null;
exif: shareAlbumQuery_album_media_exif | null;
}
export interface shareAlbumQuery_album {
__typename: "Album";
id: string;
title: string;
/**
* The albums contained in this album
*/
subAlbums: shareAlbumQuery_album_subAlbums[];
/**
* The media inside this album
*/
media: shareAlbumQuery_album_media[];
}
export interface shareAlbumQuery {
/**
* 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: shareAlbumQuery_album;
}
export interface shareAlbumQueryVariables {
id: string;
token: string;
password?: string | null;
limit?: number | null;
offset?: number | null;
}

20
ui/src/__generated__/adminQuery.ts generated Normal file
View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: adminQuery
// ====================================================
export interface adminQuery_myUser {
__typename: "User";
admin: boolean;
}
export interface adminQuery {
/**
* Information about the currently logged in user
*/
myUser: adminQuery_myUser;
}

View File

@ -0,0 +1,15 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: mapboxEnabledQuery
// ====================================================
export interface mapboxEnabledQuery {
/**
* Get the mapbox api token, returns null if mapbox is not enabled
*/
mapboxToken: string | null;
}

20
ui/src/__generated__/siteTranslation.ts generated Normal file
View File

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { LanguageTranslation } from "./../../__generated__/globalTypes";
// ====================================================
// GraphQL query operation: siteTranslation
// ====================================================
export interface siteTranslation_myUserPreferences {
__typename: "UserPreferences";
id: string;
language: LanguageTranslation | null;
}
export interface siteTranslation {
myUserPreferences: siteTranslation_myUserPreferences;
}

View File

@ -4,6 +4,8 @@ import {
split, split,
ApolloLink, ApolloLink,
HttpLink, HttpLink,
ServerError,
FieldMergeFunction,
} from '@apollo/client' } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities' import { getMainDefinition } from '@apollo/client/utilities'
import { onError } from '@apollo/client/link/error' import { onError } from '@apollo/client/link/error'
@ -12,6 +14,7 @@ import { WebSocketLink } from '@apollo/client/link/ws'
import urlJoin from 'url-join' import urlJoin from 'url-join'
import { clearTokenCookie } from './helpers/authentication' import { clearTokenCookie } from './helpers/authentication'
import { MessageState } from './components/messages/Messages' import { MessageState } from './components/messages/Messages'
import { Message } from './components/messages/SubscriptionsHook'
export const GRAPHQL_ENDPOINT = process.env.PHOTOVIEW_API_ENDPOINT export const GRAPHQL_ENDPOINT = process.env.PHOTOVIEW_API_ENDPOINT
? urlJoin(process.env.PHOTOVIEW_API_ENDPOINT, '/graphql') ? urlJoin(process.env.PHOTOVIEW_API_ENDPOINT, '/graphql')
@ -26,12 +29,12 @@ console.log('GRAPHQL ENDPOINT', GRAPHQL_ENDPOINT)
const apiProtocol = new URL(GRAPHQL_ENDPOINT).protocol const apiProtocol = new URL(GRAPHQL_ENDPOINT).protocol
let websocketUri = new URL(GRAPHQL_ENDPOINT) const websocketUri = new URL(GRAPHQL_ENDPOINT)
websocketUri.protocol = apiProtocol === 'https:' ? 'wss:' : 'ws:' websocketUri.protocol = apiProtocol === 'https:' ? 'wss:' : 'ws:'
const wsLink = new WebSocketLink({ const wsLink = new WebSocketLink({
uri: websocketUri, uri: websocketUri.toString(),
credentials: 'include', // credentials: 'include',
}) })
const link = split( const link = split(
@ -48,7 +51,7 @@ const link = split(
) )
const linkError = onError(({ graphQLErrors, networkError }) => { const linkError = onError(({ graphQLErrors, networkError }) => {
let errorMessages = [] const errorMessages = []
if (graphQLErrors) { if (graphQLErrors) {
graphQLErrors.map(({ message, locations, path }) => graphQLErrors.map(({ message, locations, path }) =>
@ -82,7 +85,7 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
console.log(`[Network error]: ${JSON.stringify(networkError)}`) console.log(`[Network error]: ${JSON.stringify(networkError)}`)
clearTokenCookie() clearTokenCookie()
const errors = networkError.result.errors const errors = (networkError as ServerError).result.errors
if (errors.length == 1) { if (errors.length == 1) {
errorMessages.push({ errorMessages.push({
@ -92,7 +95,9 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
} else if (errors.length > 1) { } else if (errors.length > 1) {
errorMessages.push({ errorMessages.push({
header: 'Multiple server errors', header: 'Multiple server errors',
content: `Received ${graphQLErrors.length} errors from the server. You are being logged out in an attempt to recover.`, content: `Received ${
graphQLErrors?.length || 0
} errors from the server. You are being logged out in an attempt to recover.`,
}) })
} }
} }
@ -106,12 +111,18 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
...msg, ...msg,
}, },
})) }))
MessageState.set(messages => [...messages, ...newMessages]) MessageState.set((messages: Message[]) => [...messages, ...newMessages])
} }
}) })
type PaginateCacheType = {
keyArgs: string[]
merge: FieldMergeFunction
}
// Modified version of Apollo's offsetLimitPagination() // Modified version of Apollo's offsetLimitPagination()
const paginateCache = keyArgs => ({ const paginateCache = (keyArgs: string[]) =>
({
keyArgs, keyArgs,
merge(existing, incoming, { args, fieldName }) { merge(existing, incoming, { args, fieldName }) {
const merged = existing ? existing.slice(0) : [] const merged = existing ? existing.slice(0) : []
@ -125,7 +136,7 @@ const paginateCache = keyArgs => ({
} }
return merged return merged
}, },
}) } as PaginateCacheType)
const memoryCache = new InMemoryCache({ const memoryCache = new InMemoryCache({
typePolicies: { typePolicies: {

View File

@ -1,6 +1,6 @@
import React, { useEffect, useContext } from 'react' import React, { useEffect, useContext } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Breadcrumb } from 'semantic-ui-react' import { Breadcrumb, IconProps } from 'semantic-ui-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import { Icon } from 'semantic-ui-react' import { Icon } from 'semantic-ui-react'
@ -8,6 +8,7 @@ import { SidebarContext } from './sidebar/Sidebar'
import AlbumSidebar from './sidebar/AlbumSidebar' import AlbumSidebar from './sidebar/AlbumSidebar'
import { useLazyQuery, gql } from '@apollo/client' import { useLazyQuery, gql } from '@apollo/client'
import { authToken } from '../helpers/authentication' import { authToken } from '../helpers/authentication'
import { albumPathQuery } from './__generated__/albumPathQuery'
const Header = styled.h1` const Header = styled.h1`
margin: 24px 0 8px 0 !important; margin: 24px 0 8px 0 !important;
@ -32,7 +33,7 @@ const StyledIcon = styled(Icon)`
} }
` `
const SettingsIcon = props => { const SettingsIcon = (props: IconProps) => {
return <StyledIcon name="settings" size="small" {...props} /> return <StyledIcon name="settings" size="small" {...props} />
} }
@ -48,8 +49,18 @@ const ALBUM_PATH_QUERY = gql`
} }
` `
const AlbumTitle = ({ album, disableLink = false }) => { type AlbumTitleProps = {
const [fetchPath, { data: pathData }] = useLazyQuery(ALBUM_PATH_QUERY) album?: {
id: string
title: string
}
disableLink: boolean
}
const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
const [fetchPath, { data: pathData }] = useLazyQuery<albumPathQuery>(
ALBUM_PATH_QUERY
)
const { updateSidebar } = useContext(SidebarContext) const { updateSidebar } = useContext(SidebarContext)
useEffect(() => { useEffect(() => {
@ -68,10 +79,7 @@ const AlbumTitle = ({ album, disableLink = false }) => {
let title = <span>{album.title}</span> let title = <span>{album.title}</span>
let path = [] const path = pathData?.album.path || []
if (pathData) {
path = pathData.album.path
}
const breadcrumbSections = path const breadcrumbSections = path
.slice() .slice()

View File

@ -1,8 +1,12 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types'
import { Loader } from 'semantic-ui-react' import { Loader } from 'semantic-ui-react'
const PaginateLoader = ({ active, text }) => ( type PaginateLoaderProps = {
active: boolean
text: string
}
const PaginateLoader = ({ active, text }: PaginateLoaderProps) => (
<Loader <Loader
style={{ margin: '42px 0 24px 0', opacity: active ? '1' : '0' }} style={{ margin: '42px 0 24px 0', opacity: active ? '1' : '0' }}
inline="centered" inline="centered"
@ -12,9 +16,4 @@ const PaginateLoader = ({ active, text }) => (
</Loader> </Loader>
) )
PaginateLoader.propTypes = {
active: PropTypes.bool,
text: PropTypes.string,
}
export default PaginateLoader export default PaginateLoader

View File

@ -0,0 +1,32 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: albumPathQuery
// ====================================================
export interface albumPathQuery_album_path {
__typename: "Album";
id: string;
title: string;
}
export interface albumPathQuery_album {
__typename: "Album";
id: string;
path: albumPathQuery_album_path[];
}
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;
}
export interface albumPathQueryVariables {
id: string;
}

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components' import styled from 'styled-components'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ProtectedImage } from '../photoGallery/ProtectedMedia' import { ProtectedImage } from '../photoGallery/ProtectedMedia'
import { albumQuery_album_subAlbums } from '../../Pages/AlbumPage/__generated__/albumQuery'
const AlbumBoxLink = styled(Link)` const AlbumBoxLink = styled(Link)`
width: 240px; width: 240px;
@ -28,7 +28,7 @@ const Image = styled(ProtectedImage)`
object-position: center; object-position: center;
` `
const Placeholder = styled.div` const Placeholder = styled.div<{ overlap?: boolean; loaded?: boolean }>`
width: 220px; width: 220px;
height: 220px; height: 220px;
border-radius: 4%; border-radius: 4%;
@ -47,14 +47,18 @@ const Placeholder = styled.div`
`} `}
` `
const AlbumBoxImage = ({ src, ...props }) => { interface AlbumBoxImageProps {
src?: string
}
const AlbumBoxImage = ({ src, ...props }: AlbumBoxImageProps) => {
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
if (src) { if (src) {
return ( return (
<ImageWrapper> <ImageWrapper>
<Image {...props} onLoad={loaded => setLoaded(loaded)} src={src} /> <Image {...props} onLoad={() => setLoaded(true)} src={src} />
<Placeholder overlap loaded={loaded ? 1 : 0} /> <Placeholder overlap loaded={loaded} />
</ImageWrapper> </ImageWrapper>
) )
} }
@ -62,11 +66,12 @@ const AlbumBoxImage = ({ src, ...props }) => {
return <Placeholder /> return <Placeholder />
} }
AlbumBoxImage.propTypes = { type AlbumBoxProps = {
src: PropTypes.string, album?: albumQuery_album_subAlbums
customLink?: string
} }
export const AlbumBox = ({ album, customLink, ...props }) => { export const AlbumBox = ({ album, customLink, ...props }: AlbumBoxProps) => {
if (!album) { if (!album) {
return ( return (
<AlbumBoxLink {...props} to="#"> <AlbumBoxLink {...props} to="#">
@ -75,7 +80,7 @@ export const AlbumBox = ({ album, customLink, ...props }) => {
) )
} }
let thumbnail = album.thumbnail?.thumbnail?.url const thumbnail = album.thumbnail?.thumbnail?.url
return ( return (
<AlbumBoxLink {...props} to={customLink || `/album/${album.id}`}> <AlbumBoxLink {...props} to={customLink || `/album/${album.id}`}>
@ -84,8 +89,3 @@ export const AlbumBox = ({ album, customLink, ...props }) => {
</AlbumBoxLink> </AlbumBoxLink>
) )
} }
AlbumBox.propTypes = {
album: PropTypes.object,
customLink: PropTypes.string,
}

View File

@ -1,6 +1,6 @@
import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { albumQuery_album_subAlbums } from '../../Pages/AlbumPage/__generated__/albumQuery'
import { AlbumBox } from './AlbumBox' import { AlbumBox } from './AlbumBox'
const Container = styled.div` const Container = styled.div`
@ -8,7 +8,14 @@ const Container = styled.div`
position: relative; position: relative;
` `
const AlbumBoxes = ({ error, albums, getCustomLink }) => { type AlbumBoxesProps = {
loading: boolean
error?: Error
albums?: albumQuery_album_subAlbums[]
getCustomLink?(albumID: string): string
}
const AlbumBoxes = ({ error, albums, getCustomLink }: AlbumBoxesProps) => {
if (error) return <div>Error {error.message}</div> if (error) return <div>Error {error.message}</div>
let albumElements = [] let albumElements = []
@ -18,7 +25,7 @@ const AlbumBoxes = ({ error, albums, getCustomLink }) => {
<AlbumBox <AlbumBox
key={album.id} key={album.id}
album={album} album={album}
customLink={getCustomLink ? getCustomLink(album.id) : null} customLink={getCustomLink ? getCustomLink(album.id) : undefined}
/> />
)) ))
} else { } else {
@ -30,11 +37,4 @@ const AlbumBoxes = ({ error, albums, getCustomLink }) => {
return <Container>{albumElements}</Container> return <Container>{albumElements}</Container>
} }
AlbumBoxes.propTypes = {
loading: PropTypes.bool.isRequired,
error: PropTypes.object,
albums: PropTypes.array,
getCustomLink: PropTypes.func,
}
export default AlbumBoxes export default AlbumBoxes

View File

@ -1,9 +1,22 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import AlbumTitle from '../AlbumTitle' import AlbumTitle from '../AlbumTitle'
import PhotoGallery from '../photoGallery/PhotoGallery' import PhotoGallery from '../photoGallery/PhotoGallery'
import AlbumBoxes from './AlbumBoxes' import AlbumBoxes from './AlbumBoxes'
import AlbumFilter from '../AlbumFilter' import AlbumFilter from '../AlbumFilter'
import { albumQuery_album } from '../../Pages/AlbumPage/__generated__/albumQuery'
import { OrderDirection } from '../../../__generated__/globalTypes'
type AlbumGalleryProps = {
album?: albumQuery_album
loading?: boolean
customAlbumLink?(albumID: string): string
showFilter?: boolean
setOnlyFavorites?(favorites: boolean): void
setOrdering?(ordering: { orderBy: string }): void
ordering?: { orderBy: string | null; orderDirection: OrderDirection | null }
onlyFavorites?: boolean
onFavorite?(): void
}
const AlbumGallery = React.forwardRef( const AlbumGallery = React.forwardRef(
( (
@ -17,18 +30,23 @@ const AlbumGallery = React.forwardRef(
ordering, ordering,
onlyFavorites = false, onlyFavorites = false,
onFavorite, onFavorite,
}, }: AlbumGalleryProps,
ref ref: React.ForwardedRef<HTMLDivElement>
) => { ) => {
const [imageState, setImageState] = useState({ type ImageStateType = {
activeImage: number
presenting: boolean
}
const [imageState, setImageState] = useState<ImageStateType>({
activeImage: -1, activeImage: -1,
presenting: false, presenting: false,
}) })
const setPresenting = presenting => const setPresenting = (presenting: boolean) =>
setImageState(state => ({ ...state, presenting })) setImageState(state => ({ ...state, presenting }))
const setPresentingWithHistory = presenting => { const setPresentingWithHistory = (presenting: boolean) => {
setPresenting(presenting) setPresenting(presenting)
if (presenting) { if (presenting) {
history.pushState({ imageState }, '') history.pushState({ imageState }, '')
@ -37,20 +55,23 @@ const AlbumGallery = React.forwardRef(
} }
} }
const updateHistory = imageState => { const updateHistory = (imageState: ImageStateType) => {
history.replaceState({ imageState }, '') history.replaceState({ imageState }, '')
return imageState return imageState
} }
const setActiveImage = activeImage => { const setActiveImage = (activeImage: number) => {
setImageState(state => updateHistory({ ...state, activeImage })) setImageState(state => updateHistory({ ...state, activeImage }))
} }
const nextImage = () => { const nextImage = () => {
if (album === undefined) return
setActiveImage((imageState.activeImage + 1) % album.media.length) setActiveImage((imageState.activeImage + 1) % album.media.length)
} }
const previousImage = () => { const previousImage = () => {
if (album === undefined) return
if (imageState.activeImage <= 0) { if (imageState.activeImage <= 0) {
setActiveImage(album.media.length - 1) setActiveImage(album.media.length - 1)
} else { } else {
@ -59,9 +80,10 @@ const AlbumGallery = React.forwardRef(
} }
useEffect(() => { useEffect(() => {
const updateImageState = event => { const updateImageState = (event: PopStateEvent) => {
setImageState(event.state.imageState) setImageState(event.state.imageState)
} }
window.addEventListener('popstate', updateImageState) window.addEventListener('popstate', updateImageState)
return () => { return () => {
@ -113,7 +135,7 @@ const AlbumGallery = React.forwardRef(
} }
<PhotoGallery <PhotoGallery
loading={loading} loading={loading}
media={album && album.media} media={album?.media || []}
activeIndex={imageState.activeImage} activeIndex={imageState.activeImage}
presenting={imageState.presenting} presenting={imageState.presenting}
onSelectImage={index => { onSelectImage={index => {
@ -129,16 +151,4 @@ const AlbumGallery = React.forwardRef(
} }
) )
AlbumGallery.propTypes = {
album: PropTypes.object,
loading: PropTypes.bool,
customAlbumLink: PropTypes.func,
showFilter: PropTypes.bool,
setOnlyFavorites: PropTypes.func,
onlyFavorites: PropTypes.bool,
onFavorite: PropTypes.func,
setOrdering: PropTypes.func,
ordering: PropTypes.object,
}
export default AlbumGallery export default AlbumGallery

View File

@ -1,11 +1,15 @@
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect } from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components' import styled from 'styled-components'
import { useLazyQuery, gql } from '@apollo/client' import { useLazyQuery, gql } from '@apollo/client'
import { debounce } from '../../helpers/utils' import { debounce, DebouncedFn } from '../../helpers/utils'
import { ProtectedImage } from '../photoGallery/ProtectedMedia' import { ProtectedImage } from '../photoGallery/ProtectedMedia'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import {
searchQuery,
searchQuery_search_albums,
searchQuery_search_media,
} from './__generated__/searchQuery'
const Container = styled.div` const Container = styled.div`
height: 60px; height: 60px;
@ -30,7 +34,7 @@ const SearchField = styled.input`
} }
` `
const Results = styled.div` const Results = styled.div<{ show: boolean }>`
display: ${({ show }) => (show ? 'block' : 'none')}; display: ${({ show }) => (show ? 'block' : 'none')};
position: absolute; position: absolute;
width: 100%; width: 100%;
@ -79,27 +83,29 @@ const SEARCH_QUERY = gql`
const SearchBar = () => { const SearchBar = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [fetchSearches, fetchResult] = useLazyQuery(SEARCH_QUERY) const [fetchSearches, fetchResult] = useLazyQuery<searchQuery>(SEARCH_QUERY)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [fetched, setFetched] = useState(false) const [fetched, setFetched] = useState(false)
let debouncedFetch = useRef(null) type QueryFn = (query: string) => void
const debouncedFetch = useRef<null | DebouncedFn<QueryFn>>(null)
useEffect(() => { useEffect(() => {
debouncedFetch.current = debounce(query => { debouncedFetch.current = debounce<QueryFn>(query => {
fetchSearches({ variables: { query } }) fetchSearches({ variables: { query } })
setFetched(true) setFetched(true)
}, 250) }, 250)
return () => { return () => {
debouncedFetch.current.cancel() debouncedFetch.current?.cancel()
} }
}, []) }, [])
const fetchEvent = e => { const fetchEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
e.persist() e.persist()
setQuery(e.target.value) setQuery(e.target.value)
if (e.target.value.trim() != '') { if (e.target.value.trim() != '' && debouncedFetch.current) {
debouncedFetch.current(e.target.value.trim()) debouncedFetch.current(e.target.value.trim())
} else { } else {
setFetched(false) setFetched(false)
@ -108,7 +114,12 @@ const SearchBar = () => {
let results = null let results = null
if (query.trim().length > 0 && fetched) { if (query.trim().length > 0 && fetched) {
results = <SearchResults result={fetchResult} /> results = (
<SearchResults
searchData={fetchResult.data}
loading={fetchResult.loading}
/>
)
} }
return ( return (
@ -128,17 +139,21 @@ const ResultTitle = styled.h1`
margin: 12px 0 0.25rem; margin: 12px 0 0.25rem;
` `
const SearchResults = ({ result }) => { type SearchResultsProps = {
const { t } = useTranslation() searchData?: searchQuery
const { data, loading } = result loading: boolean
const query = data && data.search.query }
const media = (data && data.search.media) || [] const SearchResults = ({ searchData, loading }: SearchResultsProps) => {
const albums = (data && data.search.albums) || [] const { t } = useTranslation()
const query = searchData?.search.query || ''
const media = searchData?.search.media || []
const albums = searchData?.search.albums || []
let message = null let message = null
if (loading) message = t('header.search.loading', 'Loading results...') if (loading) message = t('header.search.loading', 'Loading results...')
else if (data && media.length == 0 && albums.length == 0) else if (searchData && media.length == 0 && albums.length == 0)
message = t('header.search.no_results', 'No results found') message = t('header.search.no_results', 'No results found')
const albumElements = albums.map(album => ( const albumElements = albums.map(album => (
@ -155,7 +170,7 @@ const SearchResults = ({ result }) => {
// Prevent input blur event // Prevent input blur event
e.preventDefault() e.preventDefault()
}} }}
show={data} show={!!searchData}
> >
{message} {message}
{albumElements.length > 0 && ( {albumElements.length > 0 && (
@ -174,10 +189,6 @@ const SearchResults = ({ result }) => {
) )
} }
SearchResults.propTypes = {
result: PropTypes.object,
}
const RowLink = styled(NavLink)` const RowLink = styled(NavLink)`
display: flex; display: flex;
align-items: center; align-items: center;
@ -205,31 +216,31 @@ const RowTitle = styled.span`
padding-left: 8px; padding-left: 8px;
` `
const PhotoRow = ({ query, media }) => ( type PhotoRowArgs = {
query: string
media: searchQuery_search_media
}
const PhotoRow = ({ query, media }: PhotoRowArgs) => (
<RowLink to={`/album/${media.album.id}`}> <RowLink to={`/album/${media.album.id}`}>
<PhotoSearchThumbnail src={media?.thumbnail?.url} /> <PhotoSearchThumbnail src={media?.thumbnail?.url} />
<RowTitle>{searchHighlighted(query, media.title)}</RowTitle> <RowTitle>{searchHighlighted(query, media.title)}</RowTitle>
</RowLink> </RowLink>
) )
PhotoRow.propTypes = { type AlbumRowArgs = {
query: PropTypes.string.isRequired, query: string
media: PropTypes.object.isRequired, album: searchQuery_search_albums
} }
const AlbumRow = ({ query, album }) => ( const AlbumRow = ({ query, album }: AlbumRowArgs) => (
<RowLink to={`/album/${album.id}`}> <RowLink to={`/album/${album.id}`}>
<AlbumSearchThumbnail src={album?.thumbnail?.thumbnail?.url} /> <AlbumSearchThumbnail src={album?.thumbnail?.thumbnail?.url} />
<RowTitle>{searchHighlighted(query, album.title)}</RowTitle> <RowTitle>{searchHighlighted(query, album.title)}</RowTitle>
</RowLink> </RowLink>
) )
AlbumRow.propTypes = { const searchHighlighted = (query: string, text: string) => {
query: PropTypes.string.isRequired,
album: PropTypes.object.isRequired,
}
const searchHighlighted = (query, text) => {
const i = text.toLowerCase().indexOf(query.toLowerCase()) const i = text.toLowerCase().indexOf(query.toLowerCase())
if (i == -1) { if (i == -1) {

View File

@ -0,0 +1,76 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: searchQuery
// ====================================================
export interface searchQuery_search_albums_thumbnail_thumbnail {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface searchQuery_search_albums_thumbnail {
__typename: "Media";
/**
* URL to display the media in a smaller resolution
*/
thumbnail: searchQuery_search_albums_thumbnail_thumbnail | null;
}
export interface searchQuery_search_albums {
__typename: "Album";
id: string;
title: string;
/**
* An image in this album used for previewing this album
*/
thumbnail: searchQuery_search_albums_thumbnail | null;
}
export interface searchQuery_search_media_thumbnail {
__typename: "MediaURL";
/**
* URL for previewing the image
*/
url: string;
}
export interface searchQuery_search_media_album {
__typename: "Album";
id: string;
}
export interface searchQuery_search_media {
__typename: "Media";
id: string;
title: string;
/**
* URL to display the media in a smaller resolution
*/
thumbnail: searchQuery_search_media_thumbnail | null;
/**
* The album that holds the media
*/
album: searchQuery_search_media_album;
}
export interface searchQuery_search {
__typename: "SearchResult";
query: string;
albums: searchQuery_search_albums[];
media: searchQuery_search_media[];
}
export interface searchQuery {
search: searchQuery_search;
}
export interface searchQueryVariables {
query: string;
}

View File

@ -17,9 +17,11 @@ const Container = styled.div`
} }
` `
export let MessageState = { export const MessageState = {
set: null, set: fn => {
get: null, console.warn('set function is not defined yet, called with', fn)
},
get: [],
add: message => { add: message => {
MessageState.set(messages => { MessageState.set(messages => {
const newMessages = messages.filter(msg => msg.key != message.key) const newMessages = messages.filter(msg => msg.key != message.key)

View File

@ -1,9 +1,11 @@
import { notificationSubscription } from './__generated__/notificationSubscription'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useSubscription, gql } from '@apollo/client' import { useSubscription, gql } from '@apollo/client'
import { authToken } from '../../helpers/authentication' import { authToken } from '../../helpers/authentication'
import { NotificationType } from '../../../__generated__/globalTypes'
const notificationSubscription = gql` const NOTIFICATION_SUBSCRIPTION = gql`
subscription notificationSubscription { subscription notificationSubscription {
notification { notification {
key key
@ -18,14 +20,37 @@ const notificationSubscription = gql`
} }
` `
let messageTimeoutHandles = new Map() const messageTimeoutHandles = new Map()
const SubscriptionsHook = ({ messages, setMessages }) => { export interface Message {
key: string
type: NotificationType
timeout?: number
props: {
header: string
content: string
negative?: boolean
positive?: boolean
percent?: number
}
}
type SubscriptionHookProps = {
messages: Message[]
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
}
const SubscriptionsHook = ({
messages,
setMessages,
}: SubscriptionHookProps) => {
if (!authToken()) { if (!authToken()) {
return null return null
} }
const { data, error } = useSubscription(notificationSubscription) const { data, error } = useSubscription<notificationSubscription>(
NOTIFICATION_SUBSCRIPTION
)
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@ -33,7 +58,7 @@ const SubscriptionsHook = ({ messages, setMessages }) => {
...state, ...state,
{ {
key: Math.random().toString(26), key: Math.random().toString(26),
type: 'message', type: NotificationType.Message,
props: { props: {
header: 'Network error', header: 'Network error',
content: error.message, content: error.message,
@ -54,16 +79,16 @@ const SubscriptionsHook = ({ messages, setMessages }) => {
return return
} }
const newNotification = { const newNotification: Message = {
key: msg.key, key: msg.key,
type: msg.type.toLowerCase(), type: msg.type,
timeout: msg.timeout, timeout: msg.timeout || undefined,
props: { props: {
header: msg.header, header: msg.header,
content: msg.content, content: msg.content,
negative: msg.negative, negative: msg.negative,
positive: msg.positive, positive: msg.positive,
percent: msg.progress, percent: msg.progress || undefined,
}, },
} }

View File

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { NotificationType } from "./../../../../__generated__/globalTypes";
// ====================================================
// GraphQL subscription operation: notificationSubscription
// ====================================================
export interface notificationSubscription_notification {
__typename: "Notification";
key: string;
type: NotificationType;
header: string;
content: string;
progress: number | null;
positive: boolean;
negative: boolean;
/**
* Time in milliseconds before the notification will close
*/
timeout: number | null;
}
export interface notificationSubscription {
notification: notificationSubscription_notification;
}

View File

@ -1,9 +1,13 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { useMutation, gql } from '@apollo/client' import { useMutation, gql } from '@apollo/client'
import PropTypes from 'prop-types'
import styled from 'styled-components' import styled from 'styled-components'
import { Icon } from 'semantic-ui-react' import { Icon } from 'semantic-ui-react'
import { ProtectedImage } from './ProtectedMedia' import { ProtectedImage } from './ProtectedMedia'
import { MediaType } from '../../../__generated__/globalTypes'
import {
markMediaFavorite,
markMediaFavoriteVariables,
} from './__generated__/markMediaFavorite'
const markFavoriteMutation = gql` const markFavoriteMutation = gql`
mutation markMediaFavorite($mediaId: ID!, $favorite: Boolean!) { mutation markMediaFavorite($mediaId: ID!, $favorite: Boolean!) {
@ -24,7 +28,7 @@ const MediaContainer = styled.div`
overflow: hidden; overflow: hidden;
` `
const StyledPhoto = styled(ProtectedImage)` const StyledPhoto = styled(ProtectedImage)<{ loaded: boolean }>`
height: 200px; height: 200px;
min-width: 100%; min-width: 100%;
position: relative; position: relative;
@ -34,35 +38,30 @@ const StyledPhoto = styled(ProtectedImage)`
transition: opacity 300ms; transition: opacity 300ms;
` `
const LazyPhoto = photoProps => { type LazyPhotoProps = {
src?: string
}
const LazyPhoto = (photoProps: LazyPhotoProps) => {
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const onLoad = useCallback(e => { const onLoad = useCallback(e => {
!e.target.dataset.src && setLoaded(true) !e.target.dataset.src && setLoaded(true)
}, []) }, [])
return ( return (
<StyledPhoto <StyledPhoto {...photoProps} lazyLoading loaded={loaded} onLoad={onLoad} />
{...photoProps}
lazyLoading
loaded={loaded ? 1 : 0}
onLoad={onLoad}
/>
) )
} }
LazyPhoto.propTypes = { const PhotoOverlay = styled.div<{ active: boolean }>`
src: PropTypes.string,
}
const PhotoOverlay = styled.div`
width: 100%; width: 100%;
height: 100%; height: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
${props => ${({ active }) =>
props.active && active &&
` `
border: 4px solid rgba(65, 131, 196, 0.6); border: 4px solid rgba(65, 131, 196, 0.6);
@ -109,6 +108,24 @@ const VideoThumbnailIcon = styled(Icon)`
top: calc(50% - 13px); top: calc(50% - 13px);
` `
type MediaThumbnailProps = {
media: {
id: string
type: MediaType
favorite?: boolean
thumbnail: null | {
url: string
width: number
height: number
}
}
onSelectImage(index: number): void
index: number
active: boolean
setPresenting(presenting: boolean): void
onFavorite?(): void
}
export const MediaThumbnail = ({ export const MediaThumbnail = ({
media, media,
onSelectImage, onSelectImage,
@ -116,16 +133,19 @@ export const MediaThumbnail = ({
active, active,
setPresenting, setPresenting,
onFavorite, onFavorite,
}) => { }: MediaThumbnailProps) => {
const [markFavorite] = useMutation(markFavoriteMutation) const [markFavorite] = useMutation<
markMediaFavorite,
markMediaFavoriteVariables
>(markFavoriteMutation)
let heartIcon = null let heartIcon = null
if (typeof media.favorite == 'boolean') { if (media.favorite !== undefined) {
heartIcon = ( heartIcon = (
<FavoriteIcon <FavoriteIcon
favorite={media.favorite.toString()} favorite={media.favorite.toString()}
name={media.favorite ? 'heart' : 'heart outline'} name={media.favorite ? 'heart' : 'heart outline'}
onClick={event => { onClick={(event: MouseEvent) => {
event.stopPropagation() event.stopPropagation()
const favorite = !media.favorite const favorite = !media.favorite
markFavorite({ markFavorite({
@ -148,7 +168,7 @@ export const MediaThumbnail = ({
} }
let videoIcon = null let videoIcon = null
if (media.type == 'video') { if (media.type == MediaType.Video) {
videoIcon = <VideoThumbnailIcon name="play" size="big" /> videoIcon = <VideoThumbnailIcon name="play" size="big" />
} }
@ -163,11 +183,11 @@ export const MediaThumbnail = ({
<MediaContainer <MediaContainer
key={media.id} key={media.id}
style={{ style={{
cursor: onSelectImage ? 'pointer' : null, cursor: 'pointer',
minWidth: `clamp(124px, ${minWidth}px, 100% - 8px)`, minWidth: `clamp(124px, ${minWidth}px, 100% - 8px)`,
}} }}
onClick={() => { onClick={() => {
onSelectImage && onSelectImage(index) onSelectImage(index)
}} }}
> >
<div <div
@ -176,7 +196,7 @@ export const MediaThumbnail = ({
height: `200px`, height: `200px`,
}} }}
> >
<LazyPhoto src={media.thumbnail && media.thumbnail.url} /> <LazyPhoto src={media.thumbnail?.url} />
</div> </div>
<PhotoOverlay active={active}> <PhotoOverlay active={active}>
{videoIcon} {videoIcon}
@ -192,15 +212,6 @@ export const MediaThumbnail = ({
) )
} }
MediaThumbnail.propTypes = {
media: PropTypes.object.isRequired,
onSelectImage: PropTypes.func,
index: PropTypes.number.isRequired,
active: PropTypes.bool.isRequired,
setPresenting: PropTypes.func.isRequired,
onFavorite: PropTypes.func,
}
export const PhotoThumbnail = styled.div` export const PhotoThumbnail = styled.div`
flex-grow: 1; flex-grow: 1;
height: 200px; height: 200px;

View File

@ -3,10 +3,11 @@ import styled from 'styled-components'
import { Loader } from 'semantic-ui-react' import { Loader } from 'semantic-ui-react'
import { MediaThumbnail, PhotoThumbnail } from './MediaThumbnail' import { MediaThumbnail, PhotoThumbnail } from './MediaThumbnail'
import PresentView from './presentView/PresentView' import PresentView from './presentView/PresentView'
import PropTypes from 'prop-types'
import { SidebarContext } from '../sidebar/Sidebar' import { SidebarContext } from '../sidebar/Sidebar'
import MediaSidebar from '../sidebar/MediaSidebar' import MediaSidebar from '../sidebar/MediaSidebar'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PresentMediaProps_Media } from './presentView/PresentMedia'
import { sidebarPhoto_media_thumbnail } from '../sidebar/__generated__/sidebarPhoto'
const Gallery = styled.div` const Gallery = styled.div`
display: flex; display: flex;
@ -31,6 +32,22 @@ const ClearWrap = styled.div`
clear: both; clear: both;
` `
interface PhotoGalleryProps_Media extends PresentMediaProps_Media {
thumbnail: sidebarPhoto_media_thumbnail | null
}
type PhotoGalleryProps = {
loading: boolean
media: PhotoGalleryProps_Media[]
activeIndex: number
presenting: boolean
onSelectImage(index: number): void
setPresenting(presenting: boolean): void
nextImage(): void
previousImage(): void
onFavorite?(): void
}
const PhotoGallery = ({ const PhotoGallery = ({
activeIndex = -1, activeIndex = -1,
media, media,
@ -41,26 +58,23 @@ const PhotoGallery = ({
nextImage, nextImage,
previousImage, previousImage,
onFavorite, onFavorite,
}) => { }: PhotoGalleryProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { updateSidebar } = useContext(SidebarContext) const { updateSidebar } = useContext(SidebarContext)
const activeImage = media && activeIndex != -1 && media[activeIndex] const activeImage: PhotoGalleryProps_Media | undefined = media[activeIndex]
const getPhotoElements = updateSidebar => {
let photoElements = [] let photoElements = []
if (media) { if (media) {
media.filter(media => media.thumbnail) photoElements = media.map((media, index) => {
photoElements = media.map((photo, index) => {
const active = activeIndex == index const active = activeIndex == index
return ( return (
<MediaThumbnail <MediaThumbnail
key={photo.id} key={media.id}
media={photo} media={media}
onSelectImage={index => { onSelectImage={index => {
updateSidebar(<MediaSidebar media={photo} />) updateSidebar(<MediaSidebar media={media} />)
onSelectImage(index) onSelectImage(index)
}} }}
onFavorite={onFavorite} onFavorite={onFavorite}
@ -76,16 +90,13 @@ const PhotoGallery = ({
} }
} }
return photoElements
}
return ( return (
<ClearWrap> <ClearWrap>
<Gallery> <Gallery>
<Loader active={loading}> <Loader active={loading}>
{t('general.loading.media', 'Loading media')} {t('general.loading.media', 'Loading media')}
</Loader> </Loader>
{getPhotoElements(updateSidebar)} {photoElements}
<PhotoFiller /> <PhotoFiller />
</Gallery> </Gallery>
{presenting && ( {presenting && (
@ -98,16 +109,4 @@ const PhotoGallery = ({
) )
} }
PhotoGallery.propTypes = {
loading: PropTypes.bool,
media: PropTypes.array,
activeIndex: PropTypes.number,
presenting: PropTypes.bool,
onSelectImage: PropTypes.func,
setPresenting: PropTypes.func,
nextImage: PropTypes.func,
previousImage: PropTypes.func,
onFavorite: PropTypes.func,
}
export default PhotoGallery export default PhotoGallery

View File

@ -1,69 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
const isNativeLazyLoadSupported = 'loading' in HTMLImageElement.prototype
const placeholder = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
const getProtectedUrl = url => {
if (url == null) return null
const imgUrl = new URL(url, location.origin)
const tokenRegex = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
if (tokenRegex) {
const token = tokenRegex[1]
imgUrl.searchParams.set('token', token)
}
return imgUrl.href
}
/**
* An image that needs authorization to load
* Set lazyLoading to true if you want the image to be loaded once it enters the viewport
* Native lazy load via HTMLImageElement.loading attribute will be preferred if it is supported by the browser,
* otherwise IntersectionObserver will be used.
*/
export const ProtectedImage = ({ src, lazyLoading, ...props }) => {
if (!isNativeLazyLoadSupported && lazyLoading) {
props['data-src'] = getProtectedUrl(src)
}
if (isNativeLazyLoadSupported && lazyLoading) {
props.loading = 'lazy'
}
return (
<img
key={src}
{...props}
src={
lazyLoading && !isNativeLazyLoadSupported
? placeholder
: getProtectedUrl(src)
}
crossOrigin="use-credentials"
/>
)
}
ProtectedImage.propTypes = {
src: PropTypes.string,
lazyLoading: PropTypes.bool,
}
export const ProtectedVideo = ({ media, ...props }) => (
<video
{...props}
controls
key={media.id}
crossOrigin="use-credentials"
poster={getProtectedUrl(media.thumbnail?.url)}
>
<source src={getProtectedUrl(media.videoWeb.url)} type="video/mp4" />
</video>
)
ProtectedVideo.propTypes = {
media: PropTypes.object.isRequired,
}

View File

@ -0,0 +1,110 @@
import React, { DetailedHTMLProps, ImgHTMLAttributes } from 'react'
import { isNil } from '../../helpers/utils'
const isNativeLazyLoadSupported = 'loading' in HTMLImageElement.prototype
const placeholder =
'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
const getProtectedUrl = (url?: string) => {
if (url == undefined) return undefined
const imgUrl = new URL(url, location.origin)
const tokenRegex = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
if (tokenRegex) {
const token = tokenRegex[1]
imgUrl.searchParams.set('token', token)
}
return imgUrl.href
}
export interface ProtectedImageProps
extends Omit<
DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>,
'src'
> {
src?: string
key?: string
lazyLoading?: boolean
loaded?: boolean
}
/**
* An image that needs authorization to load
* Set lazyLoading to true if you want the image to be loaded once it enters the viewport
* Native lazy load via HTMLImageElement.loading attribute will be preferred if it is supported by the browser,
* otherwise IntersectionObserver will be used.
*/
export const ProtectedImage = ({
src,
key,
lazyLoading,
loaded,
...props
}: ProtectedImageProps) => {
const lazyLoadProps: { 'data-src'?: string; loading?: 'lazy' | 'eager' } = {}
if (!isNativeLazyLoadSupported && lazyLoading) {
lazyLoadProps['data-src'] = getProtectedUrl(src)
}
if (isNativeLazyLoadSupported && lazyLoading) {
lazyLoadProps.loading = 'lazy'
}
const imgSrc: string =
lazyLoading && !isNativeLazyLoadSupported
? placeholder
: getProtectedUrl(src) || placeholder
const loadedProp =
loaded !== undefined ? { loaded: loaded.toString() } : undefined
return (
<img
key={key}
{...props}
{...lazyLoadProps}
{...loadedProp}
src={imgSrc}
crossOrigin="use-credentials"
/>
)
}
export interface ProtectedVideoProps_Media {
__typename: 'Media'
id: string
thumbnail?: null | {
__typename: 'MediaURL'
url: string
}
videoWeb?: null | {
__typename: 'MediaURL'
url: string
}
}
export interface ProtectedVideoProps {
media: ProtectedVideoProps_Media
}
export const ProtectedVideo = ({ media, ...props }: ProtectedVideoProps) => {
if (isNil(media.videoWeb)) {
console.error('ProetctedVideo called with media.videoWeb = null')
return null
}
return (
<video
{...props}
controls
key={media.id}
crossOrigin="use-credentials"
poster={getProtectedUrl(media.thumbnail?.url)}
>
<source src={getProtectedUrl(media.videoWeb.url)} type="video/mp4" />
</video>
)
}

View File

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: markMediaFavorite
// ====================================================
export interface markMediaFavorite_favoriteMedia {
__typename: 'Media'
id: string
favorite: boolean
}
export interface markMediaFavorite {
/**
* Mark or unmark a media as being a favorite
*/
favoriteMedia: markMediaFavorite_favoriteMedia
}
export interface markMediaFavoriteVariables {
mediaId: string
favorite: boolean
}

View File

@ -1,53 +0,0 @@
import PropTypes from 'prop-types'
import React from 'react'
import styled from 'styled-components'
import { ProtectedImage, ProtectedVideo } from '../ProtectedMedia'
const StyledPhoto = styled(ProtectedImage)`
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
object-fit: contain;
object-position: center;
`
const StyledVideo = styled(ProtectedVideo)`
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
`
const PresentMedia = ({ media, imageLoaded, ...otherProps }) => {
if (media.type == 'photo') {
return (
<div {...otherProps}>
<StyledPhoto src={media.thumbnail?.url} />
<StyledPhoto
style={{ display: 'none' }}
src={media.highRes?.url}
onLoad={e => {
e.target.style.display = 'initial'
imageLoaded && imageLoaded()
}}
/>
</div>
)
}
if (media.type == 'video') {
return <StyledVideo media={media} />
}
throw new Error(`Unknown media type '${media.type}'`)
}
PresentMedia.propTypes = {
media: PropTypes.object.isRequired,
imageLoaded: PropTypes.func,
}
export default PresentMedia

Some files were not shown because too many files have changed in this diff Show More