Auth: Extend account settings with user details and avatar upload #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-10-17 19:07:38 +02:00
parent 2cf420d04a
commit 837669f796
93 changed files with 2634 additions and 1212 deletions

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-08-01 15:07+0000\n"
"POT-Creation-Date: 2022-10-17 14:51+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,331 +17,359 @@ msgstr ""
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: messages.go:87
#: messages.go:94
msgid "Unexpected error, please try again"
msgstr ""
#: messages.go:88
#: messages.go:95
msgid "Invalid request"
msgstr ""
#: messages.go:89
#: messages.go:96
msgid "Changes could not be saved"
msgstr ""
#: messages.go:90
#: messages.go:97
msgid "Could not be deleted"
msgstr ""
#: messages.go:91
#: messages.go:98
#, c-format
msgid "%s already exists"
msgstr ""
#: messages.go:92
#: messages.go:99
msgid "Not found"
msgstr ""
#: messages.go:93
#: messages.go:100
msgid "File not found"
msgstr ""
#: messages.go:94
msgid "Originals folder is empty"
msgstr ""
#: messages.go:95
msgid "Selection not found"
msgstr ""
#: messages.go:96
msgid "Entity not found"
msgstr ""
#: messages.go:97
msgid "Account not found"
msgstr ""
#: messages.go:98
msgid "User not found"
msgstr ""
#: messages.go:99
msgid "Label not found"
msgstr ""
#: messages.go:100
msgid "Album not found"
msgstr ""
#: messages.go:101
msgid "Subject not found"
msgid "File too large"
msgstr ""
#: messages.go:102
msgid "Person not found"
msgid "Wrong file type"
msgstr ""
#: messages.go:103
msgid "Face not found"
msgid "Originals folder is empty"
msgstr ""
#: messages.go:104
msgid "Not available in public mode"
msgid "Selection not found"
msgstr ""
#: messages.go:105
msgid "not available in read-only mode"
msgid "Entity not found"
msgstr ""
#: messages.go:106
msgid "Please log in and try again"
msgid "Account not found"
msgstr ""
#: messages.go:107
msgid "Upload might be offensive"
msgid "User not found"
msgstr ""
#: messages.go:108
msgid "No items selected"
msgid "Label not found"
msgstr ""
#: messages.go:109
msgid "Failed creating file, please check permissions"
msgid "Album not found"
msgstr ""
#: messages.go:110
msgid "Failed creating folder, please check permissions"
msgid "Subject not found"
msgstr ""
#: messages.go:111
msgid "Could not connect, please try again"
msgid "Person not found"
msgstr ""
#: messages.go:112
msgid "Invalid password, please try again"
msgid "Face not found"
msgstr ""
#: messages.go:113
msgid "Feature disabled"
msgid "Not available in public mode"
msgstr ""
#: messages.go:114
msgid "No labels selected"
msgid "Not available in read-only mode"
msgstr ""
#: messages.go:115
msgid "No albums selected"
msgid "Please log in to your account"
msgstr ""
#: messages.go:116
msgid "No files available for download"
msgid "Permission denied"
msgstr ""
#: messages.go:117
msgid "Failed to create zip file"
msgid "Upload might be offensive"
msgstr ""
#: messages.go:118
msgid "Invalid credentials"
msgid "Upload failed"
msgstr ""
#: messages.go:119
msgid "Invalid link"
msgid "No items selected"
msgstr ""
#: messages.go:120
msgid "Invalid name"
msgid "RunFailed creating file, please check permissions"
msgstr ""
#: messages.go:121
msgid "Busy, please try again later"
msgid "RunFailed creating folder, please check permissions"
msgstr ""
#: messages.go:122
#, c-format
msgid "The wakeup interval is %s, but must be 1h or less"
msgid "Could not connect, please try again"
msgstr ""
#: messages.go:123
msgid "Your account could not be connected"
msgid "Invalid password, please try again"
msgstr ""
#: messages.go:124
msgid "Feature disabled"
msgstr ""
#: messages.go:125
msgid "No labels selected"
msgstr ""
#: messages.go:126
msgid "Changes successfully saved"
msgid "No albums selected"
msgstr ""
#: messages.go:127
msgid "Album created"
msgid "No files available for download"
msgstr ""
#: messages.go:128
msgid "Album saved"
msgid "RunFailed to create zip file"
msgstr ""
#: messages.go:129
#, c-format
msgid "Album %s deleted"
msgid "Invalid credentials"
msgstr ""
#: messages.go:130
msgid "Album contents cloned"
msgid "Invalid link"
msgstr ""
#: messages.go:131
msgid "File removed from stack"
msgid "Invalid name"
msgstr ""
#: messages.go:132
msgid "File deleted"
msgid "Busy, please try again later"
msgstr ""
#: messages.go:133
#, c-format
msgid "Selection added to %s"
msgid "The wakeup interval is %s, but must be 1h or less"
msgstr ""
#: messages.go:134
#, c-format
msgid "One entry added to %s"
msgstr ""
#: messages.go:135
#, c-format
msgid "%d entries added to %s"
msgstr ""
#: messages.go:136
#, c-format
msgid "One entry removed from %s"
msgid "Your account could not be connected"
msgstr ""
#: messages.go:137
#, c-format
msgid "%d entries removed from %s"
msgid "Changes successfully saved"
msgstr ""
#: messages.go:138
msgid "Account created"
msgid "Album created"
msgstr ""
#: messages.go:139
msgid "Account saved"
msgid "Album saved"
msgstr ""
#: messages.go:140
msgid "Account deleted"
#, c-format
msgid "Album %s deleted"
msgstr ""
#: messages.go:141
msgid "Settings saved"
msgid "Album contents cloned"
msgstr ""
#: messages.go:142
msgid "Password changed"
msgid "File removed from stack"
msgstr ""
#: messages.go:143
#, c-format
msgid "Import completed in %d s"
msgid "File deleted"
msgstr ""
#: messages.go:144
msgid "Import canceled"
#, c-format
msgid "Selection added to %s"
msgstr ""
#: messages.go:145
#, c-format
msgid "Indexing completed in %d s"
msgid "One entry added to %s"
msgstr ""
#: messages.go:146
msgid "Indexing originals..."
#, c-format
msgid "%d entries added to %s"
msgstr ""
#: messages.go:147
#, c-format
msgid "Indexing files in %s"
msgid "One entry removed from %s"
msgstr ""
#: messages.go:148
msgid "Indexing canceled"
#, c-format
msgid "%d entries removed from %s"
msgstr ""
#: messages.go:149
#, c-format
msgid "Removed %d files and %d photos"
msgid "Account created"
msgstr ""
#: messages.go:150
#, c-format
msgid "Moving files from %s"
msgid "Account saved"
msgstr ""
#: messages.go:151
#, c-format
msgid "Copying files from %s"
msgid "Account deleted"
msgstr ""
#: messages.go:152
msgid "Labels deleted"
msgid "Settings saved"
msgstr ""
#: messages.go:153
msgid "Label saved"
msgid "Password changed"
msgstr ""
#: messages.go:154
msgid "Subject saved"
#, c-format
msgid "Import completed in %d s"
msgstr ""
#: messages.go:155
msgid "Subject deleted"
msgid "Import canceled"
msgstr ""
#: messages.go:156
msgid "Person saved"
#, c-format
msgid "Indexing completed in %d s"
msgstr ""
#: messages.go:157
msgid "Person deleted"
msgid "Indexing originals..."
msgstr ""
#: messages.go:158
#, c-format
msgid "%d files uploaded in %d s"
msgid "Indexing files in %s"
msgstr ""
#: messages.go:159
msgid "Selection approved"
msgid "Indexing canceled"
msgstr ""
#: messages.go:160
msgid "Selection archived"
#, c-format
msgid "Removed %d files and %d photos"
msgstr ""
#: messages.go:161
msgid "Selection restored"
#, c-format
msgid "Moving files from %s"
msgstr ""
#: messages.go:162
msgid "Selection marked as private"
#, c-format
msgid "Copying files from %s"
msgstr ""
#: messages.go:163
msgid "Albums deleted"
msgid "Labels deleted"
msgstr ""
#: messages.go:164
msgid "Label saved"
msgstr ""
#: messages.go:165
msgid "Subject saved"
msgstr ""
#: messages.go:166
msgid "Subject deleted"
msgstr ""
#: messages.go:167
msgid "Person saved"
msgstr ""
#: messages.go:168
msgid "Person deleted"
msgstr ""
#: messages.go:169
msgid "File uploaded"
msgstr ""
#: messages.go:170
#, c-format
msgid "%d files uploaded in %d s"
msgstr ""
#: messages.go:171
msgid "Processing upload..."
msgstr ""
#: messages.go:172
msgid "Upload has been processed"
msgstr ""
#: messages.go:173
msgid "Selection approved"
msgstr ""
#: messages.go:174
msgid "Selection archived"
msgstr ""
#: messages.go:175
msgid "Selection restored"
msgstr ""
#: messages.go:176
msgid "Selection marked as private"
msgstr ""
#: messages.go:177
msgid "Albums deleted"
msgstr ""
#: messages.go:178
#, c-format
msgid "Zip created in %d s"
msgstr ""
#: messages.go:165
#: messages.go:179
msgid "Permanently deleted"
msgstr ""
#: messages.go:166
#: messages.go:180
#, c-format
msgid "%s has been restored"
msgstr ""

View file

@ -2350,9 +2350,9 @@
}
},
"node_modules/@types/node": {
"version": "18.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.5.tgz",
"integrity": "sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q=="
"version": "18.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.0.tgz",
"integrity": "sha512-IOXCvVRToe7e0ny7HpT/X9Rb2RYtElG1a+VshjwT00HxrM2dWBApHQoqsI6WiY7Q03vdf2bCrIGzVrkF/5t10w=="
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
@ -2364,42 +2364,37 @@
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.2.tgz",
"integrity": "sha512-EDrLIPaPXOZqDjrkzxxbX7UlJSeQVgah3i0aA4pOSzmK9zq3BIh7/MZIQxED7slJByvKM4Gc6Hypyu2lJzh3SQ=="
},
"node_modules/@ungap/promise-all-settled": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
"integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q=="
},
"node_modules/@vue/compiler-core": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.40.tgz",
"integrity": "sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.41.tgz",
"integrity": "sha512-oA4mH6SA78DT+96/nsi4p9DX97PHcNROxs51lYk7gb9Z4BPKQ3Mh+BLn6CQZBw857Iuhu28BfMSRHAlPvD4vlw==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/shared": "3.2.40",
"@vue/shared": "3.2.41",
"estree-walker": "^2.0.2",
"source-map": "^0.6.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz",
"integrity": "sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.41.tgz",
"integrity": "sha512-xe5TbbIsonjENxJsYRbDJvthzqxLNk+tb3d/c47zgREDa/PCp6/Y4gC/skM4H6PIuX5DAxm7fFJdbjjUH2QTMw==",
"dependencies": {
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40"
"@vue/compiler-core": "3.2.41",
"@vue/shared": "3.2.41"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz",
"integrity": "sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.41.tgz",
"integrity": "sha512-+1P2m5kxOeaxVmJNXnBskAn3BenbTmbxBxWOtBq3mQTCokIreuMULFantBUclP0+KnzNCMOvcnKinqQZmiOF8w==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.40",
"@vue/compiler-dom": "3.2.40",
"@vue/compiler-ssr": "3.2.40",
"@vue/reactivity-transform": "3.2.40",
"@vue/shared": "3.2.40",
"@vue/compiler-core": "3.2.41",
"@vue/compiler-dom": "3.2.41",
"@vue/compiler-ssr": "3.2.41",
"@vue/reactivity-transform": "3.2.41",
"@vue/shared": "3.2.41",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7",
"postcss": "^8.1.10",
@ -2407,12 +2402,12 @@
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz",
"integrity": "sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.41.tgz",
"integrity": "sha512-Y5wPiNIiaMz/sps8+DmhaKfDm1xgj6GrH99z4gq2LQenfVQcYXmHIOBcs5qPwl7jaW3SUQWjkAPKMfQemEQZwQ==",
"dependencies": {
"@vue/compiler-dom": "3.2.40",
"@vue/shared": "3.2.40"
"@vue/compiler-dom": "3.2.41",
"@vue/shared": "3.2.41"
}
},
"node_modules/@vue/component-compiler-utils": {
@ -2455,21 +2450,21 @@
}
},
"node_modules/@vue/reactivity-transform": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz",
"integrity": "sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.41.tgz",
"integrity": "sha512-mK5+BNMsL4hHi+IR3Ft/ho6Za+L3FA5j8WvreJ7XzHrqkPq8jtF/SMo7tuc9gHjLDwKZX1nP1JQOKo9IEAn54A==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40",
"@vue/compiler-core": "3.2.41",
"@vue/shared": "3.2.41",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7"
}
},
"node_modules/@vue/shared": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.40.tgz",
"integrity": "sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ=="
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.41.tgz",
"integrity": "sha512-W9mfWLHmJhkfAmV+7gDjcHeAWALQtgGT3JErxULl0oz6R6+3ug91I7IErs93eCFhPCZPHBs4QJS7YWEV7A3sxw=="
},
"node_modules/@vvo/tzdb": {
"version": "6.71.0",
@ -3070,9 +3065,9 @@
}
},
"node_modules/axios": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.1.2.tgz",
"integrity": "sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz",
"integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
@ -3503,9 +3498,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001419",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001419.tgz",
"integrity": "sha512-aFO1r+g6R7TW+PNQxKzjITwLOyDhVRLjW0LcwS/HCZGUUKTGNp9+IwLC4xyDSZBygVL/mxaFR3HIV6wEKQuSzw==",
"version": "1.0.30001420",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001420.tgz",
"integrity": "sha512-OnyeJ9ascFA9roEj72ok2Ikp7PHJTKubtEJIQ/VK3fdsS50q4KWy+Z5X0A1/GswEItKX0ctAp8n4SYDE7wTu6A==",
"funding": [
{
"type": "opencollective",
@ -4645,9 +4640,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.282",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.282.tgz",
"integrity": "sha512-Dki0WhHNh/br/Xi1vAkueU5mtIc9XLHcMKB6tNfQKk+kPG0TEUjRh5QEMAUbRp30/rYNMFD1zKKvbVzwq/4wmg=="
"version": "1.4.283",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.283.tgz",
"integrity": "sha512-g6RQ9zCOV+U5QVHW9OpFR7rdk/V7xfopNXnyAamdpFgCHgZ1sjI8VuR1+zG2YG/TZk+tQ8mpNkug4P8FU0fuOA=="
},
"node_modules/emoji-regex": {
"version": "8.0.0",
@ -8538,11 +8533,10 @@
}
},
"node_modules/mocha": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.0.0.tgz",
"integrity": "sha512-0Wl+elVUD43Y0BqPZBzZt8Tnkw9CMUdNYnUsTfOM1vuhJVZL+kiesFYsqwBkEEuEixaiPe5ZQdqDgX2jddhmoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.1.0.tgz",
"integrity": "sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg==",
"dependencies": {
"@ungap/promise-all-settled": "1.1.2",
"ansi-colors": "4.1.1",
"browser-stdout": "1.3.1",
"chokidar": "3.5.3",
@ -11489,9 +11483,9 @@
}
},
"node_modules/socket.io": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.2.tgz",
"integrity": "sha512-6fCnk4ARMPZN448+SQcnn1u8OHUC72puJcNtSgg2xS34Cu7br1gQ09YKkO1PFfDn/wyUE9ZgMAwosJed003+NQ==",
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.3.tgz",
"integrity": "sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg==",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
@ -12170,9 +12164,9 @@
}
},
"node_modules/ua-parser-js": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz",
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==",
"version": "0.7.32",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz",
"integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==",
"funding": [
{
"type": "opencollective",
@ -12382,15 +12376,14 @@
}
},
"node_modules/util": {
"version": "0.12.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz",
"integrity": "sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw==",
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"safe-buffer": "^5.1.2",
"which-typed-array": "^1.1.2"
}
},
@ -14877,9 +14870,9 @@
}
},
"@types/node": {
"version": "18.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.5.tgz",
"integrity": "sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q=="
"version": "18.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.0.tgz",
"integrity": "sha512-IOXCvVRToe7e0ny7HpT/X9Rb2RYtElG1a+VshjwT00HxrM2dWBApHQoqsI6WiY7Q03vdf2bCrIGzVrkF/5t10w=="
},
"@types/parse-json": {
"version": "4.0.0",
@ -14891,42 +14884,37 @@
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.2.tgz",
"integrity": "sha512-EDrLIPaPXOZqDjrkzxxbX7UlJSeQVgah3i0aA4pOSzmK9zq3BIh7/MZIQxED7slJByvKM4Gc6Hypyu2lJzh3SQ=="
},
"@ungap/promise-all-settled": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
"integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q=="
},
"@vue/compiler-core": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.40.tgz",
"integrity": "sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.41.tgz",
"integrity": "sha512-oA4mH6SA78DT+96/nsi4p9DX97PHcNROxs51lYk7gb9Z4BPKQ3Mh+BLn6CQZBw857Iuhu28BfMSRHAlPvD4vlw==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/shared": "3.2.40",
"@vue/shared": "3.2.41",
"estree-walker": "^2.0.2",
"source-map": "^0.6.1"
}
},
"@vue/compiler-dom": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz",
"integrity": "sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.41.tgz",
"integrity": "sha512-xe5TbbIsonjENxJsYRbDJvthzqxLNk+tb3d/c47zgREDa/PCp6/Y4gC/skM4H6PIuX5DAxm7fFJdbjjUH2QTMw==",
"requires": {
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40"
"@vue/compiler-core": "3.2.41",
"@vue/shared": "3.2.41"
}
},
"@vue/compiler-sfc": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz",
"integrity": "sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.41.tgz",
"integrity": "sha512-+1P2m5kxOeaxVmJNXnBskAn3BenbTmbxBxWOtBq3mQTCokIreuMULFantBUclP0+KnzNCMOvcnKinqQZmiOF8w==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.40",
"@vue/compiler-dom": "3.2.40",
"@vue/compiler-ssr": "3.2.40",
"@vue/reactivity-transform": "3.2.40",
"@vue/shared": "3.2.40",
"@vue/compiler-core": "3.2.41",
"@vue/compiler-dom": "3.2.41",
"@vue/compiler-ssr": "3.2.41",
"@vue/reactivity-transform": "3.2.41",
"@vue/shared": "3.2.41",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7",
"postcss": "^8.1.10",
@ -14934,12 +14922,12 @@
}
},
"@vue/compiler-ssr": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz",
"integrity": "sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.41.tgz",
"integrity": "sha512-Y5wPiNIiaMz/sps8+DmhaKfDm1xgj6GrH99z4gq2LQenfVQcYXmHIOBcs5qPwl7jaW3SUQWjkAPKMfQemEQZwQ==",
"requires": {
"@vue/compiler-dom": "3.2.40",
"@vue/shared": "3.2.40"
"@vue/compiler-dom": "3.2.41",
"@vue/shared": "3.2.41"
}
},
"@vue/component-compiler-utils": {
@ -14975,21 +14963,21 @@
}
},
"@vue/reactivity-transform": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz",
"integrity": "sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw==",
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.41.tgz",
"integrity": "sha512-mK5+BNMsL4hHi+IR3Ft/ho6Za+L3FA5j8WvreJ7XzHrqkPq8jtF/SMo7tuc9gHjLDwKZX1nP1JQOKo9IEAn54A==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40",
"@vue/compiler-core": "3.2.41",
"@vue/shared": "3.2.41",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7"
}
},
"@vue/shared": {
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.40.tgz",
"integrity": "sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ=="
"version": "3.2.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.41.tgz",
"integrity": "sha512-W9mfWLHmJhkfAmV+7gDjcHeAWALQtgGT3JErxULl0oz6R6+3ug91I7IErs93eCFhPCZPHBs4QJS7YWEV7A3sxw=="
},
"@vvo/tzdb": {
"version": "6.71.0",
@ -15441,9 +15429,9 @@
"integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw=="
},
"axios": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.1.2.tgz",
"integrity": "sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz",
"integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==",
"requires": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
@ -15775,9 +15763,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001419",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001419.tgz",
"integrity": "sha512-aFO1r+g6R7TW+PNQxKzjITwLOyDhVRLjW0LcwS/HCZGUUKTGNp9+IwLC4xyDSZBygVL/mxaFR3HIV6wEKQuSzw=="
"version": "1.0.30001420",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001420.tgz",
"integrity": "sha512-OnyeJ9ascFA9roEj72ok2Ikp7PHJTKubtEJIQ/VK3fdsS50q4KWy+Z5X0A1/GswEItKX0ctAp8n4SYDE7wTu6A=="
},
"chai": {
"version": "4.3.6",
@ -16596,9 +16584,9 @@
}
},
"electron-to-chromium": {
"version": "1.4.282",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.282.tgz",
"integrity": "sha512-Dki0WhHNh/br/Xi1vAkueU5mtIc9XLHcMKB6tNfQKk+kPG0TEUjRh5QEMAUbRp30/rYNMFD1zKKvbVzwq/4wmg=="
"version": "1.4.283",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.283.tgz",
"integrity": "sha512-g6RQ9zCOV+U5QVHW9OpFR7rdk/V7xfopNXnyAamdpFgCHgZ1sjI8VuR1+zG2YG/TZk+tQ8mpNkug4P8FU0fuOA=="
},
"emoji-regex": {
"version": "8.0.0",
@ -19440,11 +19428,10 @@
}
},
"mocha": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.0.0.tgz",
"integrity": "sha512-0Wl+elVUD43Y0BqPZBzZt8Tnkw9CMUdNYnUsTfOM1vuhJVZL+kiesFYsqwBkEEuEixaiPe5ZQdqDgX2jddhmoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.1.0.tgz",
"integrity": "sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg==",
"requires": {
"@ungap/promise-all-settled": "1.1.2",
"ansi-colors": "4.1.1",
"browser-stdout": "1.3.1",
"chokidar": "3.5.3",
@ -21429,9 +21416,9 @@
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="
},
"socket.io": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.2.tgz",
"integrity": "sha512-6fCnk4ARMPZN448+SQcnn1u8OHUC72puJcNtSgg2xS34Cu7br1gQ09YKkO1PFfDn/wyUE9ZgMAwosJed003+NQ==",
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.3.tgz",
"integrity": "sha512-zdpnnKU+H6mOp7nYRXH4GNv1ux6HL6+lHL8g7Ds7Lj8CkdK1jJK/dlwsKDculbyOHifcJ0Pr/yeXnZQ5GeFrcg==",
"requires": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
@ -21930,9 +21917,9 @@
}
},
"ua-parser-js": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz",
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ=="
"version": "0.7.32",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz",
"integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw=="
},
"uglify-js": {
"version": "3.17.3",
@ -22053,15 +22040,14 @@
}
},
"util": {
"version": "0.12.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz",
"integrity": "sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw==",
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"requires": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"safe-buffer": "^5.1.2",
"which-typed-array": "^1.1.2"
}
},

View file

@ -9,6 +9,7 @@
"watch": "webpack --watch",
"build": "webpack --node-env=production",
"trace": "webpack --stats-children",
"debug": "webpack --stats-error-details",
"lint": "eslint --cache src/ *.js",
"fmt": "eslint --cache --fix src/ *.js .eslintrc.js",
"test": "karma start",

View file

@ -23,23 +23,23 @@ Additional information can be found in our Developer Guide:
*/
import Photos from "pages/photos.vue";
import Albums from "pages/albums.vue";
import AlbumPhotos from "pages/album/photos.vue";
import Places from "pages/places.vue";
import Browse from "pages/files/browse.vue";
import Errors from "pages/files/errors.vue";
import Labels from "pages/labels.vue";
import People from "pages/people.vue";
import Files from "pages/files.vue";
import Settings from "pages/settings.vue";
import Login from "pages/login.vue";
import Connect from "pages/connect.vue";
import Discover from "pages/discover.vue";
import About from "pages/about/about.vue";
import Feedback from "pages/about/feedback.vue";
import License from "pages/about/license.vue";
import Help from "pages/help.vue";
import Photos from "page/photos.vue";
import Albums from "page/albums.vue";
import AlbumPhotos from "page/album/photos.vue";
import Places from "page/places.vue";
import Browse from "page/library/browse.vue";
import Errors from "page/library/errors.vue";
import Labels from "page/labels.vue";
import People from "page/people.vue";
import Library from "page/library.vue";
import Settings from "page/settings.vue";
import Login from "page/login.vue";
import Connect from "page/connect.vue";
import Discover from "page/discover.vue";
import About from "page/about/about.vue";
import Feedback from "page/about/feedback.vue";
import License from "page/about/license.vue";
import Help from "page/help.vue";
import { $gettext } from "common/vm";
import { config, session } from "./session";
@ -319,25 +319,25 @@ export default [
meta: { title: $gettext("People"), auth: true, background: "application-light" },
},
{
name: "index",
name: "library_index",
path: "/index",
component: Files,
component: Library,
meta: { title: $gettext("Library"), auth: true, background: "application-light" },
props: { tab: "index" },
props: { tab: "library_index" },
},
{
name: "index_import",
name: "library_import",
path: "/import",
component: Files,
component: Library,
meta: { title: $gettext("Library"), auth: true, background: "application-light" },
props: { tab: "import" },
props: { tab: "library_import" },
},
{
name: "index_logs",
name: "library_logs",
path: "/logs",
component: Files,
component: Library,
meta: { title: $gettext("Library"), auth: true, background: "application-light" },
props: { tab: "logs" },
props: { tab: "library_logs" },
},
{
name: "settings",
@ -352,8 +352,8 @@ export default [
props: { tab: "settings-general" },
},
{
name: "settings_files",
path: "/settings/files",
name: "settings_media",
path: "/settings/media",
component: Settings,
meta: {
title: $gettext("Settings"),
@ -362,7 +362,7 @@ export default [
settings: true,
background: "application-light",
},
props: { tab: "settings-files" },
props: { tab: "settings-media" },
},
{
name: "settings_advanced",

View file

@ -209,6 +209,14 @@ export default class Session {
return this.user;
}
getUserUID() {
if (this.user && this.user.UID) {
return this.user.UID;
} else {
return "u000000000000001"; // Unknown.
}
}
loginRequired() {
return !this.config.isPublic() && !this.isUser();
}

View file

@ -387,7 +387,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-if="isMini && $config.feature('library')" :to="{ name: 'index' }" class="nav-library" @click.stop="">
<v-list-tile v-if="isMini && $config.feature('library')" :to="{ name: 'library_index' }" class="nav-library" @click.stop="">
<v-list-tile-action :title="$gettext('Library')">
<v-icon>camera_roll</v-icon>
</v-list-tile-action>
@ -401,7 +401,7 @@
<v-list-group v-if="!isMini && $config.feature('library')" prepend-icon="camera_roll" no-action>
<template #activator>
<v-list-tile :to="{ name: 'index' }" class="nav-library" @click.stop="">
<v-list-tile :to="{ name: 'library_index' }" class="nav-library" @click.stop="">
<v-list-tile-content>
<v-list-tile-title class="p-flex-menuitem">
<translate key="Library">Library</translate>
@ -519,9 +519,12 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-show="auth && !isPublic && $config.feature('settings')" to="/settings/account" class="p-profile">
<v-list-tile-avatar color="grey" size="36">
<span class="white--text headline">{{ !!displayName ? displayName[0].toUpperCase() : "E" }}</span>
<v-list-tile v-show="auth && !isPublic && $config.feature('settings')" class="p-profile" @click.stop="onAccount">
<v-list-tile-avatar size="36">
<v-img :src="user.getAvatarURL()"
:alt="displayName" aspect-ratio="1"
class="grey lighten-1 elevation-0 clickable"
></v-img>
</v-list-tile-avatar>
<v-list-tile-content>
@ -532,13 +535,13 @@
</v-list-tile-content>
<v-list-tile-action :title="$gettext('Logout')">
<v-btn icon @click.stop.prevent="logout">
<v-btn icon @click.stop.prevent="onLogout">
<v-icon>power_settings_new</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
<v-list-tile v-show="isMini && auth && !isPublic" class="nav-logout" @click.stop.prevent="logout">
<v-list-tile v-show="isMini && auth && !isPublic" class="nav-logout" @click.stop.prevent="onLogout">
<v-list-tile-action :title="$gettext('Logout')">
<v-icon>power_settings_new</v-icon>
</v-list-tile-action>
@ -555,19 +558,15 @@
<div class="menu-content grow-top-right">
<div class="menu-icons">
<a v-if="auth && !isPublic" href="#" :title="$gettext('Logout')" class="menu-action nav-logout"
@click.prevent="logout">
@click.prevent="onLogout">
<v-icon>power_settings_new</v-icon>
</a>
<a href="#" :title="$gettext('Reload')" class="menu-action nav-reload" @click.prevent="reloadApp">
<v-icon>refresh</v-icon>
</a>
<router-link v-if="!auth && !isPublic" :to="{ name: 'login' }" :title="$gettext('Login')"
class="menu-action nav-login">
<v-icon>login</v-icon>
</router-link>
<router-link v-if="auth && !routeName('index') && $config.feature('library') && $config.feature('logs')"
:to="{ name: 'logs' }" :title="$gettext('Logs')" class="menu-action nav-logs">
<v-icon>grading</v-icon>
<router-link v-if="auth && $config.feature('account')"
:to="{ name: 'settings_account' }" :title="$gettext('Account')" class="menu-action nav-account">
<v-icon>admin_panel_settings</v-icon>
</router-link>
<router-link v-if="auth && $config.feature('settings') && !routeName('settings')" :to="{ name: 'settings' }"
:title="$gettext('Settings')" class="menu-action nav-settings">
@ -577,6 +576,10 @@
class="menu-action nav-upload" @click.prevent="openUpload()">
<v-icon>cloud_upload</v-icon>
</a>
<router-link v-if="!auth && !isPublic" :to="{ name: 'login' }" :title="$gettext('Login')"
class="menu-action nav-login">
<v-icon>login</v-icon>
</router-link>
</div>
<div class="menu-actions">
<div v-if="auth && !routeName('browse')&& $config.feature('search')" class="menu-action nav-search">
@ -610,16 +613,16 @@
<translate>Files</translate>
</router-link>
</div>
<div v-if="auth && !routeName('index') && $config.feature('library')" class="menu-action nav-library">
<router-link :to="{ name: 'index' }">
<div v-if="auth && !routeName('library_index') && $config.feature('library')" class="menu-action nav-index">
<router-link :to="{ name: 'library_index' }">
<v-icon>camera_roll</v-icon>
<translate>Index</translate>
</router-link>
</div>
<div v-if="auth && $config.feature('sync') && !routeName('settings')" class="menu-action nav-sync">
<router-link :to="{ name: 'settings_sync' }">
<v-icon>sync</v-icon>
<translate>Sync</translate>
<div v-if="auth && !routeName('index') && $config.feature('library') && $config.feature('logs')" class="menu-action nav-logs">
<router-link :to="{ name: 'library_logs' }">
<v-icon>assignment</v-icon>
<translate>Logs</translate>
</router-link>
</div>
<!-- div v-if="auth && $config.feature('account') && !routeName('settings')" class="menu-action nav-account">
@ -660,6 +663,8 @@
<script>
import Album from "model/album";
import Event from "pubsub-js";
import Notify from "../common/notify";
import User from "../model/user";
export default {
name: "PNavigation",
@ -708,6 +713,7 @@ export default {
session: this.$session,
config: this.$config.values,
page: this.$config.page,
user: this.$session.getUser(),
reload: {
dialog: false,
},
@ -808,7 +814,10 @@ export default {
this.isMini = !this.isMini;
localStorage.setItem('last_navigation_mode', `${this.isMini}`);
},
logout() {
onAccount: function () {
this.$router.push({name: "settings_account"});
},
onLogout() {
this.$session.logout();
},
onIndex(ev) {

View file

@ -345,6 +345,7 @@ ol, ul {
.v-menu__content,
.v-btn.v-btn--depressed:not(.v-btn--round):not(.v-btn--icon),
.v-text-field.v-text-field--box > .v-input__control > .v-input__slot,
.v-text-field.v-text-field--solo > .v-input__control > .v-input__slot,
#photoprism .v-dialog .v-responsive.v-image,
#photoprism .cards-view .result,
@ -355,7 +356,9 @@ ol, ul {
.v-alert,
#photoprism div.v-dialog > div.v-card,
#photoprism div.v-dialog > div.v-card > .v-card__text .v-expansion-panel__container {
#photoprism div.v-dialog > div.v-card > .v-card__text .v-expansion-panel__container,
#photoprism div.v-dialog > form > div.v-card,
#photoprism div.v-dialog > form > div.v-card > .v-card__text .v-expansion-panel__container {
border-radius: 6px;
}

View file

@ -76,4 +76,20 @@
#photoprism main .auth-login footer p {
padding: 0;
margin: 0;
}
/* Account Page */
.user-avatar {
align-items: center;
border-radius: 50%;
display: inline-flex;
justify-content: center;
position: relative;
text-align: center;
vertical-align: middle;
width: 100%;
max-width: 150px;
max-height: 150px;
overflow: hidden;
}

View file

@ -29,11 +29,57 @@ body.dark-theme .theme--light.v-list a:hover {
background: rgba(255,255,255,0.3) !important;
}
body.dark-theme .v-input input::placeholder {
color: rgba(255,255,255,0.5) !important;
}
.v-text-field>.v-input__control>.v-input__slot:after,
.v-text-field>.v-input__control>.v-input__slot:before {
display: none !important;
}
/*
body.dark-theme .v-text-field.v-text-field--enclosed .v-text-field__details {
padding-left: 0;
}*/
.theme--light.v-list .v-list__tile--highlighted,
.theme--light.v-list a:hover {
background: rgba(155,155,155,0.3) !important;
}
body.dark-theme #photoprism .map-control .theme--light.v-input:not(.v-input--is-disabled) i,
body.dark-theme #photoprism .map-control .theme--light.v-input:not(.v-input--is-disabled) input {
color: #333333;
}
body.dark-theme #photoprism .theme--light.v-chip,
body.dark-theme #photoprism .v-card .theme--light.v-text-field--box>.v-input__control>.v-input__slot,
body.dark-theme #photoprism .v-card .theme--light.v-text-field--solo>.v-input__control>.v-input__slot {
background: #00000033;
}
body.dark-theme #photoprism,
body.dark-theme #photoprism .p-page a,
body.dark-theme #photoprism .v-datatable a,
body.dark-theme #photoprism .theme--light.v-expansion-panel .v-expansion-panel__container,
body.dark-theme #photoprism .theme--light.v-tabs__bar .v-tabs__div,
body.dark-theme #photoprism .theme--light {
color: #ffffff;
}
body.dark-theme #photoprism .theme--light.v-label {
color: #ffffffaa;
}
body.dark-theme #photoprism .theme--light.v-select .v-select__selections {
color: #ffffffee;
}
#photoprism .v-select.v-text-field--box .v-select__selections {
padding-top: 20px;
}
/* Exceptions */
#photoprism .theme--light.v-text-field--solo.background-inherit>.v-input__control>.v-input__slot {

View file

@ -0,0 +1,135 @@
<template>
<v-dialog :value="show" lazy persistent max-width="500" class="modal-dialog p-account-password-dialog" @keydown.esc="cancel">
<v-form ref="form" dense class="form-password" accept-charset="UTF-8">
<v-card raised elevation="24">
<v-card-title primary-title class="pa-2">
<v-layout row wrap class="pa-2">
<v-flex xs9 class="text-xs-left">
<h3 class="headline pa-0"><translate>Change Password</translate></h3>
</v-flex>
<v-flex xs3 class="text-xs-right">
<v-icon size="28" color="primary">lock</v-icon>
</v-flex>
</v-layout>
</v-card-title>
<v-card-text class="py-0 px-2">
<v-layout wrap align-top>
<v-flex xs12 class="px-2 pb-2 caption">
<translate>Please note that changing your password will log you out on other devices and browsers.</translate>
</v-flex>
<v-flex xs12 class="px-2 py-1">
<v-text-field
v-model="oldPassword"
hide-details required box flat
type="password"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Current Password')"
class="input-current-password"
color="secondary-dark"
></v-text-field>
</v-flex>
<v-flex xs12 class="px-2 py-1">
<v-text-field
v-model="newPassword"
required counter persistent-hint box flat
type="password"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('New Password')"
class="input-new-password"
color="secondary-dark"
:hint="$gettext('Must have at least 8 characters.')"
></v-text-field>
</v-flex>
<v-flex xs12 class="px-2 py-1">
<v-text-field
v-model="confirmPassword"
required counter persistent-hint box flat
type="password"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Retype Password')"
class="input-retype-password"
color="secondary-dark"
:hint="$gettext('Please confirm your new password.')"
@keyup.enter.native="confirm"
></v-text-field>
</v-flex>
</v-layout>
</v-card-text>
<v-card-actions class="pt-1 pb-2 px-2">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-right>
<v-btn depressed color="secondary-light"
class="action-cancel ml-0"
@click.stop="cancel">
<translate>Cancel</translate>
</v-btn>
<v-btn depressed color="primary-button"
class="action-confirm white--text compact mr-0"
:disabled="disabled()"
@click.stop="confirm">
<translate>Save</translate>
</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
</v-form>
</v-dialog>
</template>
<script>
export default {
name: 'PAccountPasswordDialog',
props: {
show: Boolean,
},
data() {
return {
busy: false,
isDemo: this.$config.get("demo"),
isPublic: this.$config.get("public"),
oldPassword: "",
newPassword: "",
confirmPassword: "",
rtl: this.$rtl,
};
},
computed: {},
created() {
if(this.isPublic && !this.isDemo) {
this.$emit('cancel');
}
},
methods: {
disabled() {
return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < 8 || (this.newPassword !== this.confirmPassword));
},
confirm() {
this.busy = true;
this.$session.getUser().changePassword(this.oldPassword, this.newPassword).then(() => {
this.$notify.success(this.$gettext("Password changed"));
this.$emit('confirm');
}).finally(() => {
this.busy = false;
});
},
cancel() {
if (this.busy) {
return;
}
this.$emit('cancel');
},
},
};
</script>

View file

@ -20,7 +20,7 @@
<v-layout row wrap>
<v-flex v-if="album.Type !== 'month'" xs12 pa-2>
<v-text-field v-model="model.Title"
hide-details autofocus
hide-details autofocus box flat
:rules="[titleRule]"
:label="$gettext('Name')"
:disabled="disabled"
@ -31,7 +31,7 @@
</v-flex>
<v-flex xs12 pa-2>
<v-text-field v-model="model.Location"
hide-details
hide-details box flat
:label="$gettext('Location')"
:disabled="disabled"
color="secondary-dark"
@ -41,7 +41,7 @@
<v-flex xs12 pa-2>
<v-textarea :key="growDesc" v-model="model.Description"
auto-grow
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('Description')"
:rows="1"
@ -51,7 +51,7 @@
</v-textarea>
</v-flex>
<v-flex xs12 md6 pa-2>
<v-combobox v-model="model.Category" hide-details
<v-combobox v-model="model.Category" hide-details box flat
:search-input.sync="model.Category"
:items="categories"
:disabled="disabled"
@ -67,7 +67,7 @@
<v-select
v-model="model.Order"
:label="$gettext('Sort Order')"
hide-details
hide-details box flat
:items="sorting"
:disabled="disabled"
item-value="value"
@ -78,7 +78,7 @@
</v-layout>
</v-container>
</v-card-text>
<v-card-actions class="pt-0">
<v-card-actions class="pt-0 px-3">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-right>
<v-btn depressed color="secondary-light"

View file

@ -23,9 +23,9 @@ Additional information can be found in our Developer Guide:
*/
import PAccountAddDialog from "dialog/service/add.vue";
import PAccountRemoveDialog from "dialog/service/remove.vue";
import PAccountEditDialog from "dialog/service/edit.vue";
import PServiceAddDialog from "dialog/service/add.vue";
import PServiceRemoveDialog from "dialog/service/remove.vue";
import PServiceEditDialog from "dialog/service/edit.vue";
import PPhotoArchiveDialog from "dialog/photo/archive.vue";
import PPhotoAlbumDialog from "dialog/photo/album.vue";
import PPhotoEditDialog from "dialog/photo/edit.vue";
@ -47,9 +47,9 @@ import PConfirmDialog from "dialog/confirm.vue";
const dialogs = {};
dialogs.install = (Vue) => {
Vue.component("PAccountAddDialog", PAccountAddDialog);
Vue.component("PAccountRemoveDialog", PAccountRemoveDialog);
Vue.component("PAccountEditDialog", PAccountEditDialog);
Vue.component("PServiceAddDialog", PServiceAddDialog);
Vue.component("PServiceRemoveDialog", PServiceRemoveDialog);
Vue.component("PServiceEditDialog", PServiceEditDialog);
Vue.component("PPhotoArchiveDialog", PPhotoArchiveDialog);
Vue.component("PPhotoAlbumDialog", PPhotoAlbumDialog);
Vue.component("PPhotoEditDialog", PPhotoEditDialog);

View file

@ -1,7 +1,7 @@
<template>
<v-dialog :value="show" lazy persistent max-width="350" class="p-photo-album-dialog" @keydown.esc="cancel">
<v-card raised elevation="24">
<v-container fluid class="pb-2 pr-2 pl-2">
<v-card-text class="pt-3 pl-0 pr-2">
<v-layout row wrap>
<v-flex xs3 text-xs-center>
<v-icon size="56" color="secondary-dark lighten-1">photo_album</v-icon>
@ -15,30 +15,33 @@
:items="items"
:search-input.sync="search"
:loading="loading"
hide-details
hide-no-data
hide-no-data hide-details box flat
item-text="Title"
item-value="UID"
:label="$gettext('Album Name')"
color="secondary-dark"
flat solo
class="input-album"
@keyup.enter.native="confirm"
>
</v-autocomplete>
</v-flex>
<v-flex xs12 text-xs-right class="pt-3">
<v-btn depressed color="secondary-light" class="action-cancel" @click.stop="cancel">
</v-layout>
</v-card-text>
<v-card-actions class="pt-1 pb-2 px-2">
<v-layout row wrap class="pa-0">
<v-flex xs12 text-xs-right>
<v-btn depressed color="secondary-light" class="action-cancel ml-0" @click.stop="cancel">
<translate>Cancel</translate>
</v-btn>
<v-btn color="primary-button" depressed dark class="action-confirm"
<v-btn depressed color="primary-button"
class="action-confirm white--text compact mr-0"
@click.stop="confirm">
<span v-if="!album">{{ labels.createAlbum }}</span>
<span v-else>{{ labels.addToAlbum }}</span>
</v-btn>
</v-flex>
</v-layout>
</v-container>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@ -129,7 +132,7 @@ export default {
this.$nextTick(() => this.$refs.input.focus());
}).finally(() => {
this.loading = false;
})
});
},
},
};

View file

@ -109,11 +109,11 @@
</template>
<script>
import Photo from "model/photo";
import PhotoDetails from "details.vue";
import PhotoLabels from "labels.vue";
import PhotoPeople from "people.vue";
import PhotoFiles from "files.vue";
import PhotoInfo from "info.vue";
import PhotoDetails from "./edit/details.vue";
import PhotoLabels from "./edit/labels.vue";
import PhotoPeople from "./edit/people.vue";
import PhotoFiles from "./edit/files.vue";
import PhotoInfo from "./edit/info.vue";
import Event from "pubsub-js";
export default {
@ -126,7 +126,10 @@ export default {
'p-tab-photo-info': PhotoInfo,
},
props: {
index: Number,
index: {
type: Number,
default: 0,
},
show: Boolean,
selection: {
type: Array,

View file

@ -30,7 +30,7 @@
:append-icon="model.TitleSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
:rules="[textRule]"
hide-details
hide-details box flat
:label="$gettext('Title')"
placeholder=""
color="secondary-dark"
@ -47,7 +47,7 @@
:error="invalidDate"
:label="$gettext('Day')"
browser-autocomplete="off"
hide-details hide-no-data
hide-details box flat hide-no-data
color="secondary-dark"
:items="options.Days()"
class="input-day"
@ -62,7 +62,7 @@
:error="invalidDate"
:label="$gettext('Month')"
browser-autocomplete="off"
hide-details hide-no-data
hide-details box flat hide-no-data
color="secondary-dark"
:items="options.MonthsShort()"
class="input-month"
@ -77,7 +77,7 @@
:error="invalidDate"
:label="$gettext('Year')"
browser-autocomplete="off"
hide-details hide-no-data
hide-details box flat hide-no-data
color="secondary-dark"
:items="options.Years()"
class="input-year"
@ -94,7 +94,7 @@
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
hide-details
hide-details box flat
return-masked-value mask="##:##:##"
color="secondary-dark"
class="input-local-time"
@ -107,7 +107,7 @@
:disabled="disabled"
:label="$gettext('Time Zone')"
browser-autocomplete="off"
hide-details hide-no-data
hide-details box flat hide-no-data
color="secondary-dark"
item-value="ID"
item-text="Name"
@ -123,7 +123,7 @@
:append-icon="model.PlaceSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
:readonly="!!(model.Lat || model.Lng)"
:label="$gettext('Country')" hide-details
:label="$gettext('Country')" hide-details box flat
hide-no-data
browser-autocomplete="off"
color="secondary-dark"
@ -138,7 +138,7 @@
<v-text-field
v-model="model.Altitude"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
@ -154,7 +154,7 @@
v-model="model.Lat"
:append-icon="model.PlaceSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
@ -170,7 +170,7 @@
v-model="model.Lng"
:append-icon="model.PlaceSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
@ -188,7 +188,7 @@
:disabled="disabled"
:label="$gettext('Camera')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-value="ID"
item-text="Name"
@ -201,7 +201,7 @@
<v-text-field
v-model="model.Iso"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
@ -216,7 +216,7 @@
<v-text-field
v-model="model.Exposure"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
@ -234,7 +234,7 @@
:disabled="disabled"
:label="$gettext('Lens')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-value="ID"
item-text="Name"
@ -247,7 +247,7 @@
<v-text-field
v-model="model.FNumber"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
@ -262,7 +262,7 @@
<v-text-field
v-model="model.FocalLength"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('Focal Length')"
placeholder=""
@ -277,7 +277,7 @@
:append-icon="model.Details.SubjectSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
:rules="[textRule]"
hide-details
hide-details box flat
browser-autocomplete="off"
auto-grow
:label="$gettext('Subject')"
@ -294,7 +294,7 @@
:append-icon="model.Details.ArtistSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
:rules="[textRule]"
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('Artist')"
placeholder=""
@ -309,7 +309,7 @@
:append-icon="model.Details.CopyrightSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
:rules="[textRule]"
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('Copyright')"
placeholder=""
@ -324,7 +324,7 @@
:append-icon="model.Details.LicenseSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
:rules="[textRule]"
hide-details
hide-details box flat
browser-autocomplete="off"
auto-grow
:label="$gettext('License')"
@ -340,7 +340,7 @@
v-model="model.Description"
:append-icon="model.DescriptionSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
auto-grow
:label="$gettext('Description')"
@ -356,7 +356,7 @@
v-model="model.Details.Keywords"
:append-icon="model.Details.KeywordsSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
auto-grow
:label="$gettext('Keywords')"
@ -372,7 +372,7 @@
v-model="model.Details.Notes"
:append-icon="model.Details.NotesSrc === 'manual' ? 'check' : ''"
:disabled="disabled"
hide-details
hide-details box flat
browser-autocomplete="off"
auto-grow
:label="$gettext('Notes')"
@ -411,13 +411,20 @@
<script>
import countries from "options/countries.json";
import Thumb from "model/thumb";
import Photo from "model/photo";
import * as options from "options/options";
export default {
name: 'PTabPhotoDetails',
props: {
model: Object,
uid: String,
model: {
type: Object,
default: () => new Photo(false),
},
uid: {
type: String,
default: "",
},
},
data() {
return {

View file

@ -1,19 +1,21 @@
<template>
<v-dialog :value="show" lazy persistent max-width="500" class="p-account-create-dialog" @keydown.esc="cancel">
<v-card raised elevation="24">
<v-card-title primary-title>
<div>
<h3 class="headline mx-2 my-0">
<translate>Add Server</translate>
</h3>
</div>
<v-card-title primary-title class="pa-2">
<v-layout row wrap>
<v-flex xs12 class="pa-2">
<h3 class="headline pa-0">
<translate>Add Account</translate>
</h3>
</v-flex>
</v-layout>
</v-card-title>
<v-card-text class="pt-0">
<v-card-text class="pb-0 pt-0 px-2">
<v-layout row wrap>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="model.AccURL"
hide-details autofocus
hide-details autofocus box flat
:label="$gettext('Service URL')"
placeholder="https://www.example.com/"
color="secondary-dark"
@ -24,7 +26,7 @@
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="model.AccUser"
hide-details
hide-details box flat
:label="$gettext('Username')"
placeholder="optional"
color="secondary-dark"
@ -35,7 +37,7 @@
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="model.AccPass"
hide-details
hide-details box flat
autocorrect="off"
autocapitalize="none"
:label="$gettext('Password')"
@ -46,22 +48,26 @@
@click:append="showPassword = !showPassword"
></v-text-field>
</v-flex>
<v-flex xs12 text-xs-left class="pa-2 caption">
</v-layout>
</v-card-text>
<v-card-actions class="pt-1 pb-2 px-2">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-left class="caption">
<translate>Note: Only WebDAV servers, like Nextcloud or PhotoPrism, can be configured as remote service for backup and file upload.</translate>
<translate>Support for additional services, like Google Drive, will be added over time.</translate>
</v-flex>
<v-flex xs12 text-xs-right class="px-2 pt-2 pb-0">
<v-flex xs12 text-xs-right class="pt-2">
<v-btn depressed color="secondary-light" class="action-cancel mr-2"
@click.stop="cancel">
<span>{{ label.cancel }}</span>
</v-btn>
<v-btn depressed dark color="primary-button" class="action-confirm ma-0"
<v-btn depressed dark color="primary-button" class="action-confirm compact ma-0"
@click.stop="confirm">
<span>{{ label.confirm }}</span>
</v-btn>
</v-flex>
</v-layout>
</v-card-text>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View file

@ -1,10 +1,10 @@
<template>
<v-dialog :value="show" lazy persistent max-width="500" class="p-account-edit-dialog" @keydown.esc="cancel">
<v-card raised elevation="24">
<v-card-title primary-title>
<v-layout v-if="scope === 'sharing'" row wrap>
<v-card-title primary-title class="pa-2">
<v-layout v-if="scope === 'sharing'" row wrap class="py-2 pr-0 pl-2">
<v-flex xs9>
<h3 class="headline mx-2 my-0">{{ $gettext('Manual Upload') }}</h3>
<h3 class="headline ma-0 pa-0">{{ $gettext('Manual Upload') }}</h3>
</v-flex>
<v-flex xs3 text-xs-right>
<v-switch
@ -12,9 +12,8 @@
color="secondary-dark"
:true-value="true"
:false-value="false"
:label="model.AccShare ? $gettext('Enabled') : $gettext('Disabled')"
:disabled="model.AccType !== 'webdav'"
class="ma-0 hidden-xs-only"
class="ma-0 hidden-xs-only float-right"
hide-details
></v-switch>
<v-switch
@ -23,14 +22,14 @@
:true-value="true"
:false-value="false"
:disabled="model.AccType !== 'webdav'"
class="ma-0 hidden-sm-and-up"
class="ma-0 hidden-sm-and-up float-right"
hide-details
></v-switch>
</v-flex>
</v-layout>
<v-layout v-else-if="scope === 'sync'" row wrap>
<v-layout v-else-if="scope === 'sync'" row wrap class="pa-2">
<v-flex xs9>
<h3 class="headline mx-2 my-0">{{ $gettext('Remote Sync') }}</h3>
<h3 class="headline ma-0 pa-0">{{ $gettext('Remote Sync') }}</h3>
</v-flex>
<v-flex xs3 text-xs-right>
<v-switch
@ -38,10 +37,9 @@
color="secondary-dark"
:true-value="true"
:false-value="false"
:label="model.AccSync ? $gettext('Enabled') : $gettext('Disabled')"
:disabled="model.AccType !== 'webdav'"
class="mt-0 hidden-xs-only"
hide-details
class="mt-0 hidden-xs-only float-right"
hide-details box flat
></v-switch>
<v-switch
v-model="model.AccSync"
@ -49,14 +47,14 @@
:true-value="true"
:false-value="false"
:disabled="model.AccType !== 'webdav'"
class="mt-0 hidden-sm-and-up"
hide-details
class="mt-0 hidden-sm-and-up float-right"
hide-details box flat
></v-switch>
</v-flex>
</v-layout>
<v-layout v-else row wrap>
<v-layout v-else row wrap class="pt-2 pr-0 pl-2">
<v-flex xs10>
<h3 class="headline mx-2 my-0">{{ $gettext('Edit Account') }}</h3>
<h3 class="headline ma-0 pa-0">{{ $gettext('Edit Account') }}</h3>
</v-flex>
<v-flex xs2 text-xs-right>
<v-btn icon flat :ripple="false"
@ -67,13 +65,12 @@
</v-flex>
</v-layout>
</v-card-title>
<v-card-text class="pt-0">
<v-card-text class="py-0 px-2">
<v-layout v-if="scope === 'sharing'" row wrap>
<v-flex xs12 class="pa-2">
<v-autocomplete
v-model="model.SharePath"
color="secondary-dark" hide-details hide-no-data
flat
color="secondary-dark" hide-details hide-no-data box flat
browser-autocomplete="off"
hint="Folder"
:search-input.sync="search"
@ -92,7 +89,7 @@
:disabled="!model.AccShare"
:label="$gettext('Size')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-text="text"
item-value="value"
@ -105,7 +102,7 @@
:disabled="!model.AccShare"
:label="$gettext('Expires')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-text="text"
item-value="value"
@ -117,8 +114,7 @@
<v-flex xs12 sm6 class="pa-2">
<v-autocomplete
v-model="model.SyncPath"
color="secondary-dark" hide-details hide-no-data
flat
color="secondary-dark" hide-details hide-no-data box flat
browser-autocomplete="off"
:hint="$gettext('Folder')"
:search-input.sync="search"
@ -137,7 +133,7 @@
:disabled="!model.AccSync"
:label="$gettext('Interval')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-text="text"
item-value="value"
@ -148,7 +144,7 @@
<v-checkbox
v-model="model.SyncDownload"
:disabled="!model.AccSync || readonly"
hide-details
hide-details box flat
color="secondary-dark"
on-icon="radio_button_checked"
off-icon="radio_button_unchecked"
@ -160,7 +156,7 @@
<v-checkbox
v-model="model.SyncFilenames"
:disabled="!model.AccSync"
hide-details
hide-details box flat
color="secondary-dark"
:label="$gettext('Preserve filenames')"
></v-checkbox>
@ -169,7 +165,7 @@
<v-checkbox
v-model="model.SyncUpload"
:disabled="!model.AccSync"
hide-details
hide-details box flat
color="secondary-dark"
on-icon="radio_button_checked"
off-icon="radio_button_unchecked"
@ -181,17 +177,17 @@
<v-checkbox
v-model="model.SyncRaw"
:disabled="!model.AccSync"
hide-details
hide-details box flat
color="secondary-dark"
:label="$gettext('Sync raw and video files')"
></v-checkbox>
</v-flex>
</v-layout>
<v-layout v-else row wrap>
<v-layout v-else row wrap class="pt-0">
<v-flex xs12 class="pa-2">
<v-text-field
v-model="model.AccName"
hide-details autofocus
hide-details autofocus box flat
browser-autocomplete="off"
:label="$gettext('Name')"
placeholder=""
@ -202,7 +198,7 @@
<v-flex xs12 class="pa-2">
<v-text-field
v-model="model.AccURL"
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('Service URL')"
placeholder="https://www.example.com/"
@ -212,7 +208,7 @@
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="model.AccUser"
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('Username')"
placeholder="optional"
@ -222,7 +218,7 @@
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="model.AccPass"
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('Password')"
placeholder="optional"
@ -235,7 +231,7 @@
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="model.AccKey"
hide-details
hide-details box flat
browser-autocomplete="off"
:label="$gettext('API Key')"
placeholder="optional"
@ -248,7 +244,7 @@
v-model="model.AccType"
:label="$gettext('Type')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-text="text"
item-value="value"
@ -260,7 +256,7 @@
v-model="model.AccTimeout"
:label="$gettext('Timeout')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-text="text"
item-value="value"
@ -272,7 +268,7 @@
v-model="model.RetryLimit"
:label="$gettext('Retry Limit')"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-text="text"
item-value="value"
@ -280,19 +276,21 @@
</v-select>
</v-flex>
</v-layout>
<v-layout row wrap>
</v-card-text>
<v-card-actions class="pt-0 pb-2 px-2">
<v-layout row wrap class="pa-2">
<v-flex xs12 text-xs-right class="pt-3 pb-0">
<v-btn depressed color="secondary-light" class="action-cancel"
@click.stop="cancel">
<translate>Cancel</translate>
</v-btn>
<v-btn depressed dark color="primary-button" class="action-save"
<v-btn depressed dark color="primary-button" class="action-save compact"
@click.stop="save">
<translate>Save</translate>
</v-btn>
</v-flex>
</v-layout>
</v-card-text>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@ -303,7 +301,10 @@ export default {
name: 'PAccountEditDialog',
props: {
show: Boolean,
scope: String,
scope: {
type: String,
default: "",
},
model: {
type: Object,
default: () => {

View file

@ -39,7 +39,7 @@
autocorrect="off"
autocapitalize="none"
browser-autocomplete="off"
hide-details
hide-details box flat
readonly
color="secondary-dark"
class="input-url"
@ -51,7 +51,7 @@
v-model="link.Expires"
:label="expires(link)"
browser-autocomplete="off"
hide-details
hide-details box flat
color="secondary-dark"
item-text="text"
item-value="value"
@ -93,7 +93,7 @@
</v-btn>
</v-flex>
<v-flex xs6 :text-xs-right="!rtl" :text-xs-left="rtl" class="pa-2">
<v-btn depressed dark color="primary-button" class="ma-0 action-save"
<v-btn depressed dark color="primary-button" class="ma-0 compact action-save"
@click.stop.exact="update(link)">
<translate>Save</translate>
</v-btn>
@ -112,7 +112,7 @@
<translate>Alternatively, you can upload files directly to WebDAV servers like Nextcloud.</translate>
</v-container>
</v-card-text>
<v-card-actions class="pt-0">
<v-card-actions class="pt-0 px-3">
<v-layout row wrap class="pa-2">
<v-flex xs6>
<v-btn depressed color="secondary-light" class="action-webdav"

View file

@ -21,7 +21,7 @@
<v-select
v-model="service"
color="secondary-dark" hide-details hide-no-data
flat
box flat
:label="$gettext('Account')"
item-text="AccName"
item-value="ID"
@ -35,7 +35,7 @@
<v-autocomplete
v-model="path"
color="secondary-dark" hide-details hide-no-data
flat
box flat
browser-autocomplete="off"
hint="Folder"
:search-input.sync="search"

View file

@ -25,7 +25,7 @@
<translate>Feel free to contact us at hello@photoprism.app if you have any questions.</translate>
</p>
</v-card-text>
<v-card-actions class="pt-0">
<v-card-actions class="pt-0 px-3">
<v-layout row wrap class="px-2">
<v-flex xs12 sm4 text-xs-right text-sm-left class="py-2">
<v-btn depressed color="secondary-light"

View file

@ -12,7 +12,7 @@
</v-toolbar>
<v-container grid-list-xs ext-xs-left fluid>
<v-form ref="form" class="p-photo-upload" lazy-validation dense @submit.prevent="submit">
<input ref="upload" type="file" multiple class="d-none input-upload" @change.stop="upload()">
<input ref="upload" type="file" multiple class="d-none input-upload" @change.stop="onUpload()">
<v-container fluid>
<p class="subheading">
@ -74,7 +74,7 @@
color="primary-button"
class="white--text ml-0 mt-2 action-upload"
depressed
@click.stop="uploadDialog()"
@click.stop="onUploadDialog()"
>
<translate key="Upload">Upload</translate>
<v-icon :right="!rtl" :left="rtl" dark>cloud_upload</v-icon>
@ -146,7 +146,7 @@ export default {
},
cancel() {
if (this.busy) {
Notify.info(this.$gettext("Uploading photos…"));
Notify.info(this.$gettext("Uploading…"));
return;
}
@ -154,7 +154,7 @@ export default {
},
confirm() {
if (this.busy) {
Notify.info(this.$gettext("Uploading photos…"));
Notify.info(this.$gettext("Uploading…"));
return;
}
@ -163,9 +163,6 @@ export default {
submit() {
// DO NOTHING
},
uploadDialog() {
this.$refs.upload.click();
},
reset() {
this.busy = false;
this.selected = [];
@ -177,7 +174,10 @@ export default {
this.completed = 0;
this.token = "";
},
upload() {
onUploadDialog() {
this.$refs.upload.click();
},
onUpload() {
if (this.busy) {
return;
}
@ -199,7 +199,9 @@ export default {
this.completed = 0;
this.uploads = [];
Notify.info(this.$gettext("Uploading photos…"));
let userUid = this.$session.getUserUID();
Notify.info(this.$gettext("Uploading…"));
let addToAlbums = [];
@ -222,7 +224,7 @@ export default {
formData.append('files', file);
await Api.post('upload/' + ctx.token,
await Api.post(`users/${userUid}/upload/${ctx.token}`,
formData,
{
headers: {
@ -240,9 +242,7 @@ export default {
performUpload(this).then(() => {
this.indexing = true;
const ctx = this;
Api.post('import/upload/' + this.token, {
move: true,
Api.post(`users/${userUid}/upload/${ctx.token}`,{
albums: addToAlbums,
}).then(() => {
ctx.reset();
@ -250,7 +250,7 @@ export default {
ctx.$emit('confirm');
}).catch(() => {
ctx.reset();
Notify.error(ctx.$gettext("Failure while importing uploaded files"));
Notify.error(ctx.$gettext("Upload failed"));
});
});
},

View file

@ -80,6 +80,7 @@ export default {
data() {
return {
visible: false,
user: this.$session.getUser(),
};
},
watch: {
@ -111,14 +112,29 @@ export default {
}
},
webdavUrl() {
return `${window.location.protocol}//admin@${window.location.host}/originals/`;
let baseUrl = `${window.location.protocol}//${this.user.Name}@${window.location.host}/originals/`;
if (this.user.BasePath) {
baseUrl = `${baseUrl}${this.user.BasePath}/`;
}
return baseUrl;
},
windowsUrl() {
let baseUrl = "";
if (window.location.protocol === "https") {
return `\\\\${window.location.host}@SSL\\originals\\`;
baseUrl = `\\\\${window.location.host}@SSL\\originals\\`;
} else {
return `\\\\${window.location.host}\\originals\\`;
baseUrl = `\\\\${window.location.host}\\originals\\`;
}
if (this.user.BasePath) {
const basePath = this.user.BasePath.replace(/\//g, '\\');
baseUrl = `${baseUrl}${basePath}\\`;
}
return baseUrl;
},
windowsHelp(ev) {
window.open('https://docs.photoprism.app/user-guide/sync/webdav/#connect-to-a-webdav-server', '_blank');

View file

@ -71,7 +71,19 @@ msgstr "Abyss"
#: src/component/navigation.vue:73 src/dialog/share/upload.vue:112
#: src/model/account.js:98 src/pages/settings.vue:74
msgid "Account"
msgstr "Profil"
msgstr "Konto"
msgid "Services"
msgstr "Dienste"
msgid "Change Password"
msgstr "Passwort ändern"
msgid "Add Account"
msgstr "Konto hinzufügen"
msgid "Please note that changing your password will log you out on other devices and browsers."
msgstr "Bitte beachte, dass du beim Ändern deines Passworts auf anderen Geräten und Browsern abgemeldet wirst."
#: src/dialog/photo/info.vue:159
msgid "Accuracy"
@ -306,7 +318,7 @@ msgstr "Diese Kategorien wirklich löschen?"
#: src/dialog/account/remove.vue:10
msgid "Are you sure you want to delete this account?"
msgstr "Diesen Account wirklich löschen?"
msgstr "Dieses Konto wirklich löschen?"
#: src/dialog/photo/delete.vue:10
msgid "Are you sure you want to permanently delete these pictures?"
@ -822,7 +834,7 @@ msgstr "%{name} bearbeiten"
#: src/dialog/account/edit.vue:140
msgid "Edit Account"
msgstr "Account bearbeiten"
msgstr "Konto bearbeiten"
#: src/dialog/photo/edit.vue:51
msgid "Edit Photo"
@ -1906,7 +1918,7 @@ msgstr "Wird neu geladen…"
#: src/dialog/account/edit.vue:96
msgid "Remote Sync"
msgstr "Fern-Synchronisation"
msgstr "Synchronisation"
#: src/dialog/photo/people.vue:108
msgid "Remove"
@ -2029,7 +2041,7 @@ msgstr "Server"
#: src/dialog/account/add.vue:65 src/dialog/account/edit.vue:420
#: src/dialog/share.vue:22
msgid "Service URL"
msgstr "Service URL"
msgstr "Dienst-URL"
#: src/app/routes.js:326 src/app/routes.js:338 src/app/routes.js:350
#: src/app/routes.js:362 src/app/routes.js:374 src/component/navigation.vue:407

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@ import Form from "common/form";
import Util from "common/util";
import Api from "common/api";
import { T, $gettext } from "common/vm";
import { config } from "app/session";
export class User extends RestModel {
getDefaults() {
@ -66,33 +67,35 @@ export class User extends RestModel {
UpdatedAt: "",
},
Details: {
IdURL: "",
SubjUID: "",
SubjSrc: "",
PlaceID: "",
PlaceSrc: "",
CellID: "",
IdURL: "",
AvatarURL: "",
SiteURL: "",
FeedURL: "",
UserGender: "",
BirthYear: -1,
BirthMonth: -1,
BirthDay: -1,
NamePrefix: "",
GivenName: "",
MiddleName: "",
FamilyName: "",
NameSuffix: "",
NickName: "",
UserPhone: "",
UserAddress: "",
UserCountry: "",
UserBio: "",
JobTitle: "",
Department: "",
Company: "",
CompanyURL: "",
BirthYear: -1,
BirthMonth: -1,
BirthDay: -1,
Gender: "",
Bio: "",
Location: "",
Country: "",
Phone: "",
SiteURL: "",
ProfileURL: "",
FeedURL: "",
AvatarURL: "",
OrgName: "",
OrgTitle: "",
OrgEmail: "",
OrgPhone: "",
OrgURL: "",
CreatedAt: "",
UpdatedAt: "",
},
@ -149,6 +152,36 @@ export class User extends RestModel {
);
}
getAvatarURL(size) {
if (!this.Thumb) {
return `${config.contentUri}/svg/user`;
}
if (!size) {
size = "tile_500";
}
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
}
uploadAvatar(files) {
if (this.busy) {
return;
} else if (!files || files.length !== 1) {
return;
}
let file = files[0];
let formData = new FormData();
let formConf = { headers: { "Content-Type": "multipart/form-data" } };
formData.append("files", file);
return Api.post(this.getEntityResource() + `/avatar`, formData, formConf).then((response) =>
Promise.resolve(this.setValues(response.data))
);
}
getProfileForm() {
return Api.options(this.getEntityResource() + "/profile").then((response) =>
Promise.resolve(new Form(response.data))
@ -162,8 +195,8 @@ export class User extends RestModel {
}).then((response) => Promise.resolve(response.data));
}
saveProfile() {
return Api.post(this.getEntityResource() + "/profile", this.getValues()).then((response) =>
save() {
return Api.post(this.getEntityResource(), this.getValues()).then((response) =>
Promise.resolve(this.setValues(response.data))
);
}

View file

@ -46,8 +46,8 @@
</template>
<script>
import tabColors from "pages/discover/colors.vue";
import tabTodo from "pages/discover/todo.vue";
import tabColors from "page/discover/colors.vue";
import tabTodo from "page/discover/todo.vue";
export default {
name: 'PPageSettings',

View file

@ -8,7 +8,7 @@
slider-color="secondary-dark"
:height="$vuetify.breakpoint.smAndDown ? 48 : 64"
>
<v-tab v-for="(item, index) in tabs" :id="'tab-' + item.name" :key="manage" :class="item.class" ripple
<v-tab v-for="(item, index) in tabs" :id="'tab-' + item.name" :key="index" :class="item.class" ripple
@click="changePath(item.path)">
<v-icon v-if="$vuetify.breakpoint.smAndDown" :title="item.label">{{ item.icon }}</v-icon>
<template v-else>
@ -17,7 +17,7 @@
</v-tab>
<v-tabs-items touchless>
<v-tab-item v-for="(item, index) in tabs" :key="manage" lazy>
<v-tab-item v-for="(item, index) in tabs" :key="index" lazy>
<component :is="item.component"></component>
</v-tab-item>
</v-tabs-items>
@ -26,9 +26,9 @@
</template>
<script>
import Import from "pages/files/import.vue";
import Index from "pages/files/index.vue";
import Logs from "pages/files/logs.vue";
import Import from "page/library/import.vue";
import Index from "page/library/index.vue";
import Logs from "page/library/logs.vue";
function initTabs(flag, tabs) {
let i = 0;
@ -58,7 +58,7 @@ export default {
const tabs = [
{
'name': 'index',
'name': 'library_index',
'component': Index,
'label': this.$gettext('Index'),
'class': '',
@ -68,7 +68,7 @@ export default {
'demo': true,
},
{
'name': 'import',
'name': 'library_import',
'component': Import,
'label': this.$gettext('Import'),
'class': '',
@ -81,12 +81,12 @@ export default {
if(this.$config.feature('logs')) {
tabs.push({
'name': 'logs',
'name': 'library_logs',
'component': Logs,
'label': this.$gettext('Logs'),
'class': '',
'path': '/logs',
'icon': 'grading',
'icon': 'assignment',
'readonly': true,
'demo': true,
});
@ -102,7 +102,9 @@ export default {
let active = 0;
if (typeof this.tab === 'string' && this.tab !== '') {
if (typeof this.$route.name === 'string' && this.$route.name !== '') {
active = tabs.findIndex((t) => t.name === this.$route.name);
} else if (typeof this.tab === 'string' && this.tab !== '') {
active = tabs.findIndex((t) => t.name === this.tab);
}
@ -116,6 +118,24 @@ export default {
rtl: this.$rtl,
};
},
watch: {
'$route'() {
let active = this.active;
if (typeof this.$route.name === 'string' && this.$route.name !== '') {
active = this.tabs.findIndex((t) => t.name === this.$route.name);
}
if (active >= 0) {
this.active = active;
}
}
},
created() {
if (!this.tabs || this.tabs.length === 0) {
this.$router.push({ name: "albums" });
}
},
methods: {
changePath: function (path) {
if (this.$route.path !== path) {

View file

@ -4,6 +4,7 @@
v-model="active"
flat
grow
touchless
color="secondary"
slider-color="secondary-dark"
:height="$vuetify.breakpoint.smAndDown ? 48 : 64"
@ -33,8 +34,8 @@
</template>
<script>
import Recognized from "pages/people/recognized.vue";
import NewFaces from "pages/people/new.vue";
import Recognized from "page/people/recognized.vue";
import NewFaces from "page/people/new.vue";
export default {
name: 'PPagePeople',

View file

@ -112,7 +112,7 @@
</v-layout>
<div class="text-xs-center mt-3 mb-2">
<v-btn
color="secondary" round
color="secondary" round depressed
:to="{name: 'all', query: { q: 'face:new' }}"
>
<translate>Show all new faces</translate>

View file

@ -1,5 +1,5 @@
<template>
<div :class="$config.aclClasses('settings')" class="p-page p-page-settings">
<div class="p-page p-page-settings" :class="$config.aclClasses('settings')">
<v-tabs
v-model="active"
flat
@ -28,11 +28,11 @@
</template>
<script>
import General from "pages/settings/general.vue";
import Files from "pages/settings/files.vue";
import Advanced from "pages/settings/advanced.vue";
import Services from "pages/settings/services.vue";
import Account from "pages/settings/account.vue";
import General from "page/settings/general.vue";
import Library from "page/settings/library.vue";
import Advanced from "page/settings/advanced.vue";
import Services from "page/settings/services.vue";
import Account from "page/settings/account.vue";
import {config} from "app/session";
function initTabs(flag, tabs) {
@ -60,7 +60,7 @@ export default {
const tabs = [
{
'name': 'settings-general',
'name': 'settings_general',
'component': General,
'label': this.$gettext('General'),
'class': '',
@ -72,11 +72,11 @@ export default {
'show': config.feature('settings'),
},
{
'name': 'settings-files',
'component': Files,
'name': 'settings_media',
'component': Library,
'label': this.$gettext('Library'),
'class': '',
'path': '/settings/files',
'path': '/settings/media',
'icon': 'camera_roll',
'public': true,
'admin': true,
@ -84,7 +84,7 @@ export default {
'show': config.allow("config", "manage"),
},
{
'name': 'settings-advanced',
'name': 'settings_advanced',
'component': Advanced,
'label': this.$gettext('Advanced'),
'class': '',
@ -96,7 +96,7 @@ export default {
'show': config.allow("config", "manage"),
},
{
'name': 'settings-services',
'name': 'settings_services',
'component': Services,
'label': this.$gettext('Services'),
'class': '',
@ -108,7 +108,7 @@ export default {
'show': config.feature('services') && config.allow("services", "manage"),
},
{
'name': 'settings-account',
'name': 'settings_account',
'component': Account,
'label': this.$gettext('Account'),
'class': '',
@ -131,7 +131,9 @@ export default {
let active = 0;
if (typeof this.tab === 'string' && this.tab !== '') {
if (typeof this.$route.name === 'string' && this.$route.name !== '') {
active = tabs.findIndex((t) => t.name === this.$route.name);
} else if (typeof this.tab === 'string' && this.tab !== '') {
active = tabs.findIndex((t) => t.name === this.tab);
}
@ -144,6 +146,24 @@ export default {
rtl: this.$rtl,
};
},
watch: {
'$route'() {
let active = this.active;
if (typeof this.$route.name === 'string' && this.$route.name !== '') {
active = this.tabs.findIndex((t) => t.name === this.$route.name);
}
if (active >= 0) {
this.active = active;
}
}
},
created() {
if (!this.tabs || this.tabs.length === 0) {
this.$router.push({ name: "albums" });
}
},
methods: {
changePath: function (path) {
if (this.$route.path !== path) {

View file

@ -0,0 +1,352 @@
<template>
<div class="p-tab p-settings-account">
<v-form ref="form" lazy-validation
dense class="p-form-account" accept-charset="UTF-8"
@submit.prevent="onChange">
<input ref="upload" type="file" class="d-none input-upload" @change.stop="onUploadAvatar()">
<v-card flat tile class="mt-2 px-1 application">
<v-card-actions>
<v-layout row wrap align-top>
<v-flex
class="p-photo pa-3 text-xs-center"
xs4 sm3 md2 xl1 fill-height
>
<div class="user-avatar" @click.exact="onChangeAvatar()">
<v-img :src="user.getAvatarURL()"
:alt="displayName" aspect-ratio="1"
class="grey lighten-1 elevation-0 clickable"
></v-img>
</div>
</v-flex>
<v-flex xs8 sm9 md10 xl11 fill-height class="pa-0">
<v-layout wrap align-top>
<v-flex xs12 md3 class="pa-2">
<v-text-field
v-model="user.Name"
hide-details required box flat readonly
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Username')"
class="input-name"
color="secondary-dark"
></v-text-field>
</v-flex>
<v-flex xs12 md9 class="pa-2">
<v-text-field
v-model="user.DisplayName"
hide-details required box flat
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Display Name')"
class="input-display-name"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="user.Email"
hide-details required box flat
type="email"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Email')"
class="input-email"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
</v-layout>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
<v-card flat tile class="mt-0 px-1 application">
<v-card-title primary-title class="pb-1">
<h3 class="body-2 mb-0">
<translate>Security and Access</translate>
</h3>
</v-card-title>
<v-card-actions>
<v-layout wrap align-top>
<v-flex xs12 sm6 class="pa-2">
<v-btn block depressed color="secondary-light" class="action-change-password compact" :disabled="isPublic || isDemo || user.Name === ''"
@click.stop="showDialog('password')">
<translate>Change Password</translate>
<v-icon :right="!rtl" :left="rtl" dark>lock</v-icon>
</v-btn>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-btn block depressed color="secondary-light" class="action-webdav-dialog compact"
:disabled="isPublic || isDemo || !user.WebDAV" @click.stop="showDialog('webdav')">
<translate>Connect via WebDAV</translate>
<v-icon :right="!rtl" :left="rtl" dark>sync_alt</v-icon>
</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
<v-card flat tile class="mt-0 px-1 application">
<v-card-title primary-title class="pb-1">
<h3 class="body-2 mb-0">
<translate>Work Details</translate>
</h3>
</v-card-title>
<v-card-actions>
<v-layout wrap align-top>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.OrgName"
hide-details required box flat
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Organization')"
class="input-org-name"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs6 class="pa-2">
<v-text-field
v-model="user.Details.OrgURL"
hide-details required box flat
:disabled="busy"
type="url"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('URL')"
class="input-org-url"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs6 class="pa-2">
<v-text-field
v-model="user.Details.OrgTitle"
hide-details required box flat
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Title')"
class="input-position"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.OrgEmail"
hide-details required box flat
:disabled="busy"
type="email"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Email')"
class="input-org-email"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
<v-card flat tile class="mt-0 px-1 application">
<v-card-title primary-title class="pb-1">
<h3 class="body-2 mb-0">
<translate>Contact Details</translate>
</h3>
</v-card-title>
<v-card-actions>
<v-layout wrap align-top>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="user.Details.Location"
hide-details required box flat
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Location')"
class="input-location"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-autocomplete
v-model="user.Details.Country"
:disabled="busy"
:label="$gettext('Country')" hide-details box flat
hide-no-data
browser-autocomplete="off"
color="secondary-dark"
item-value="Code"
item-text="Name"
:items="countries"
class="input-country"
@change="onChange"
>
</v-autocomplete>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.Phone"
hide-details required box flat
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Phone')"
class="input-phone"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.ProfileURL"
hide-details required box flat
:disabled="busy"
type="url"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Profile')"
class="input-profile-url"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.FeedURL"
hide-details required box flat
:disabled="busy"
type="url"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Feed')"
class="input-feed-url"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 class="pa-2">
<v-textarea v-model="user.Details.Bio" auto-grow flat box hide-details
rows="3" class="input-bio" color="secondary-dark"
autocorrect="off" autocapitalize="none" browser-autocomplete="off"
:disabled="busy"
:label="$gettext('Bio')"
@change="onChange"></v-textarea>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
</v-form>
<p-about-footer></p-about-footer>
<p-account-password-dialog :show="dialog.password" @cancel="dialog.password = false" @confirm="dialog.password = false"></p-account-password-dialog>
<p-webdav-dialog :show="dialog.webdav" @close="dialog.webdav = false"></p-webdav-dialog>
</div>
</template>
<script>
import PAccountPasswordDialog from "dialog/account/password.vue";
import countries from "options/countries.json";
import Notify from "common/notify";
import Api from "common/api";
import User from "model/user";
export default {
name: 'PSettingsAccount',
components: {PAccountPasswordDialog},
data() {
const isDemo = this.$config.isDemo();
const isPublic = this.$config.isPublic();
return {
busy: isDemo || isPublic,
isDemo: isDemo,
isPublic: isPublic,
rtl: this.$rtl,
user: new User(this.$session.getUser()),
countries: countries,
dialog: {
password: false,
webdav: false,
},
};
},
created() {
if(this.isPublic && !this.isDemo) {
this.$router.push({ name: "settings" });
}
},
computed: {
displayName() {
const user = this.$session.getUser();
if (user) {
return user.getDisplayName();
}
return this.$gettext("Unregistered");
},
},
methods: {
showDialog(name) {
if (!name) {
return;
}
this.dialog[name] = true;
},
disabled() {
return (this.isDemo || this.busy);
},
onChangeAvatar() {
if (this.busy) {
return;
}
this.$refs.upload.click();
},
onLogout() {
this.$session.logout();
},
onChange() {
if (this.busy) {
return;
}
this.busy = true;
this.user.update().then((u) => {
this.user = new User(u);
this.$session.setUser(u);
this.$notify.success(this.$gettext("Settings saved"));
}).finally(() => this.busy = false);
},
onUploadAvatar() {
if (this.busy) {
return;
}
this.busy = true;
Notify.info(this.$gettext("Uploading…"));
this.user.uploadAvatar(this.$refs.upload.files).then((u) => {
this.user = new User(u);
this.$session.setUser(u);
this.$notify.success(this.$gettext("Settings saved"));
}).finally(() => this.busy = false);
}
},
};
</script>

View file

@ -1,5 +1,5 @@
<template>
<div class="p-tab p-settings-general">
<div class="p-tab p-settings-advanced">
<v-form ref="form" lazy-validation
dense class="p-form-settings" accept-charset="UTF-8"
@submit.prevent="onChange">
@ -332,7 +332,7 @@ import ConfigOptions from "model/config-options";
import * as options from "options/options";
export default {
name: 'PSettingsServer',
name: 'PSettingsAdvanced',
data() {
return {
busy: this.$config.get("demo"),

View file

@ -1,5 +1,5 @@
<template>
<div class="p-tab p-settings-general">
<div class="p-tab p-settings-library">
<v-form ref="form" lazy-validation
dense class="p-form-settings" accept-charset="UTF-8"
@submit.prevent="onChange">
@ -160,7 +160,7 @@ export default {
this.$config.load().then(() => {
this.settings.setValues(this.$config.settings());
this.busy = false;
})
});
},
onChange() {
const reload = this.settings.changed("ui", "language");

View file

@ -1,5 +1,5 @@
<template>
<div class="p-tab p-settings-sync">
<div class="p-tab p-settings-services">
<v-data-table
v-model="selected"
:headers="listColumns"
@ -8,7 +8,7 @@
disable-initial-sort
class="elevation-0 p-accounts p-accounts-list p-results"
item-key="ID"
:no-data-text="$gettext('No servers configured.')"
:no-data-text="$gettext('No services configured.')"
>
<template #items="props">
<td class="p-account">
@ -64,27 +64,28 @@
dense class="p-form-settings mt-2" accept-charset="UTF-8"
@submit.prevent="add">
<v-btn depressed color="secondary-light" class="action-webdav-dialog compact ml-0 my-2 mr-2"
<v-btn v-if="user.WebDAV" depressed color="secondary-light" class="action-webdav-dialog compact ml-0 my-2 mr-2"
:disabled="isPublic || isDemo" @click.stop="webdavDialog">
<translate>Connect via WebDAV</translate>
<v-icon :right="rtl" :left="!rtl" dark>sync_alt</v-icon>
</v-btn>
<v-btn color="primary-button"
class="white--text compact ml-0 my-2 mr-2"
:disabled="isPublic || isDemo"
depressed @click.stop="add">
<translate>Add Server</translate>
<translate>Connect</translate>
<v-icon :right="!rtl" :left="rtl" dark>add</v-icon>
</v-btn>
</v-form>
</v-container>
<p-account-add-dialog :show="dialog.add" @cancel="onCancel('add')"
@confirm="onAdded"></p-account-add-dialog>
<p-account-remove-dialog :show="dialog.remove" :model="model" @cancel="onCancel('remove')"
@confirm="onRemoved"></p-account-remove-dialog>
<p-account-edit-dialog :show="dialog.edit" :model="model" :scope="editScope" @remove="remove(model)"
<p-service-add-dialog :show="dialog.add" @cancel="onCancel('add')"
@confirm="onAdded"></p-service-add-dialog>
<p-service-remove-dialog :show="dialog.remove" :model="model" @cancel="onCancel('remove')"
@confirm="onRemoved"></p-service-remove-dialog>
<p-service-edit-dialog :show="dialog.edit" :model="model" :scope="editScope" @remove="remove(model)"
@cancel="onCancel('edit')"
@confirm="onEdited"></p-account-edit-dialog>
@confirm="onEdited"></p-service-edit-dialog>
<p-webdav-dialog :show="dialog.webdav" @close="dialog.webdav = false"></p-webdav-dialog>
</div>
</template>
@ -95,7 +96,7 @@ import Service from "model/service";
import {DateTime} from "luxon";
export default {
name: 'PSettingsSync',
name: 'PSettingsServices',
data() {
return {
isDemo: this.$config.get("demo"),
@ -107,6 +108,7 @@ export default {
results: [],
labels: {},
selected: [],
user: this.$session.getUser(),
dialog: {
add: false,
remove: false,
@ -114,7 +116,7 @@ export default {
},
editScope: "main",
listColumns: [
{text: this.$gettext('Server'), value: 'AccName', sortable: false, align: 'left'},
{text: this.$gettext('Name'), value: 'AccName', sortable: false, align: 'left'},
{text: this.$gettext('Upload'), value: 'AccShare', sortable: false, align: 'center'},
{text: this.$gettext('Sync'), value: 'AccSync', sortable: false, align: 'center'},
{

View file

@ -1,114 +0,0 @@
<template>
<div class="p-tab p-settings-account">
<v-form ref="form" dense class="form-password" accept-charset="UTF-8">
<v-card flat tile class="ma-2 application">
<v-card-actions>
<v-layout wrap align-top>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="oldPassword"
hide-details required
type="password"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Current Password')"
class="input-current-password"
color="secondary-dark"
placeholder="••••••••"
></v-text-field>
</v-flex>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="newPassword"
required counter persistent-hint
type="password"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('New Password')"
class="input-new-password"
color="secondary-dark"
placeholder="••••••••"
:hint="$gettext('Must have at least 8 characters.')"
></v-text-field>
</v-flex>
<v-flex xs12 class="pa-2">
<v-text-field
v-model="confirmPassword"
required counter persistent-hint
type="password"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Retype Password')"
class="input-retype-password"
color="secondary-dark"
placeholder="••••••••"
:hint="$gettext('Please confirm your new password.')"
@keyup.enter.native="confirm"
></v-text-field>
</v-flex>
<v-flex xs12 class="pa-2">
<p class="caption pa-0">
<translate>Note: Updating your password will invalidate other browser sessions, so you will have to log in again.</translate>
</p>
</v-flex>
<v-flex xs12 class="pa-2">
<v-btn depressed color="primary-button"
:disabled="disabled()"
class="action-confirm compact white--text ma-0"
@click.stop="confirm">
<translate>Change</translate>
<v-icon :right="!rtl" :left="rtl" dark>keyboard_return</v-icon>
</v-btn>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
</v-form>
<p-about-footer></p-about-footer>
</div>
</template>
<script>
export default {
name: 'PSettingsAccount',
data() {
return {
busy: false,
isDemo: this.$config.get("demo"),
isPublic: this.$config.get("public"),
oldPassword: "",
newPassword: "",
confirmPassword: "",
rtl: this.$rtl,
};
},
created() {
if(this.isPublic && !this.isDemo) {
this.$router.push({ name: "settings" });
}
},
methods: {
disabled() {
return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < 8 || (this.newPassword !== this.confirmPassword));
},
confirm() {
this.busy = true;
this.$session.getUser().changePassword(this.oldPassword, this.newPassword).then(() => {
this.$notify.success(this.$gettext("Password changed"));
}).finally(() => this.busy = false);
},
},
};
</script>

View file

@ -188,14 +188,14 @@ func UpdateService(router *gin.RouterGroup) {
}
// 2) Update form with values from request
if err := c.BindJSON(&f); err != nil {
if err = c.BindJSON(&f); err != nil {
log.Error(err)
AbortBadRequest(c)
return
}
// 3) Save model with values from form
if err := m.SaveForm(f); err != nil {
if err = m.SaveForm(f); err != nil {
log.Error(err)
AbortSaveFailed(c)
return

View file

@ -6,6 +6,15 @@ import (
"github.com/gin-gonic/gin"
)
var userIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M11.1 35.25q3.15-2.2 6.25-3.375Q20.45 30.7 24 30.7q3.55 0 6.675 1.175t6.275 3.375q2.2-2.7 3.125-5.45Q41 27.05 41 24q0-7.25-4.875-12.125T24 7q-7.25 0-12.125 4.875T7 24q0 3.05.95 5.8t3.15 5.45ZM24 25.5q-2.9 0-4.875-1.975T17.15 18.65q0-2.9 1.975-4.875T24 11.8q2.9 0 4.875 1.975t1.975 4.875q0 2.9-1.975 4.875T24 25.5ZM24 44q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.15 1.575-7.775t4.3-6.35q2.725-2.725 6.375-4.3Q19.9 4 24 4q4.15 0 7.775 1.575t6.35 4.3q2.725 2.725 4.3 6.35Q44 19.85 44 24q0 4.1-1.575 7.75-1.575 3.65-4.3 6.375-2.725 2.725-6.35 4.3Q28.15 44 24 44Zm0-3q2.75 0 5.375-.8t5.175-2.8q-2.55-1.8-5.2-2.75-2.65-.95-5.35-.95-2.7 0-5.35.95-2.65.95-5.2 2.75 2.55 2 5.175 2.8Q21.25 41 24 41Zm0-18.5q1.7 0 2.775-1.075t1.075-2.775q0-1.7-1.075-2.775T24 14.8q-1.7 0-2.775 1.075T20.15 18.65q0 1.7 1.075 2.775T24 22.5Zm0-3.85Zm0 18.7Z"/></svg>`)
var faceIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M31.3 21.35q1.15 0 1.925-.8.775-.8.775-1.9 0-1.15-.775-1.925-.775-.775-1.925-.775-1.1 0-1.9.775-.8.775-.8 1.925 0 1.1.8 1.9.8.8 1.9.8Zm-14.6 0q1.15 0 1.925-.8.775-.8.775-1.9 0-1.15-.775-1.925-.775-.775-1.925-.775-1.1 0-1.9.775-.8.775-.8 1.925 0 1.1.8 1.9.8.8 1.9.8Zm7.3 13.6q3.3 0 6.075-1.775Q32.85 31.4 34.1 28.35h-2.6q-1.15 2-3.15 3.075-2 1.075-4.3 1.075-2.35 0-4.375-1.05t-3.125-3.1H13.9q1.3 3.05 4.05 4.825Q20.7 34.95 24 34.95ZM24 44q-4.15 0-7.8-1.575-3.65-1.575-6.35-4.275-2.7-2.7-4.275-6.35Q4 28.15 4 24t1.575-7.8Q7.15 12.55 9.85 9.85q2.7-2.7 6.35-4.275Q19.85 4 24 4t7.8 1.575q3.65 1.575 6.35 4.275 2.7 2.7 4.275 6.35Q44 19.85 44 24t-1.575 7.8q-1.575 3.65-4.275 6.35-2.7 2.7-6.35 4.275Q28.15 44 24 44Zm0-20Zm0 17q7.1 0 12.05-4.95Q41 31.1 41 24q0-7.1-4.95-12.05Q31.1 7 24 7q-7.1 0-12.05 4.95Q7 16.9 7 24q0 7.1 4.95 12.05Q16.9 41 24 41Z"/></svg>`)
var cameraIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M24 34.7q3.6 0 6.05-2.45 2.45-2.45 2.45-6.05 0-3.65-2.45-6.075Q27.6 17.7 24 17.7q-3.65 0-6.075 2.425Q15.5 22.55 15.5 26.2q0 3.6 2.425 6.05Q20.35 34.7 24 34.7ZM7 42q-1.2 0-2.1-.9Q4 40.2 4 39V13.35q0-1.15.9-2.075.9-.925 2.1-.925h7.35L18 6h12l3.65 4.35H41q1.15 0 2.075.925Q44 12.2 44 13.35V39q0 1.2-.925 2.1-.925.9-2.075.9Z"/></svg>`)
var photoIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>`)
@ -41,8 +50,22 @@ var uncachedIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/>
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>`)
// GetSvg returns SVG placeholder symbols.
//
// GET /api/v1/svg/*
func GetSvg(router *gin.RouterGroup) {
router.GET("/svg/user", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", userIconSvg)
})
router.GET("/svg/face", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", faceIconSvg)
})
router.GET("/svg/camera", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", cameraIconSvg)
})
router.GET("/svg/photo", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
})

View file

@ -1,118 +0,0 @@
package api
import (
"net/http"
"os"
"path"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/clean"
)
// Upload adds files to the import folder, from where supported file types are moved to the originals folders.
//
// POST /api/v1/upload/:path
func Upload(router *gin.RouterGroup) {
router.POST("/upload/:token", func(c *gin.Context) {
conf := get.Config()
if conf.ReadOnly() || !conf.Settings().Features.Upload {
Abort(c, http.StatusForbidden, i18n.ErrReadOnly)
return
}
s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionManage, acl.ActionUpload})
if s.Abort(c) {
return
}
start := time.Now()
token := clean.Token(c.Param("token"))
f, err := c.MultipartForm()
if err != nil {
log.Errorf("upload: %s", err)
AbortBadRequest(c)
return
}
event.Publish("upload.start", event.Data{"time": start})
files := f.File["files"]
uploaded := len(files)
var uploads []string
uploadDir := path.Join(conf.ImportPath(), UploadPath, s.RefID+token)
if err = os.MkdirAll(uploadDir, os.ModePerm); err != nil {
log.Errorf("upload: failed creating folder %s", clean.Log(filepath.Base(uploadDir)))
AbortBadRequest(c)
return
}
for _, file := range files {
filename := path.Join(uploadDir, filepath.Base(file.Filename))
log.Debugf("upload: saving file %s", clean.Log(file.Filename))
if err := c.SaveUploadedFile(file, filename); err != nil {
log.Errorf("upload: failed saving file %s", clean.Log(filepath.Base(file.Filename)))
AbortBadRequest(c)
return
}
uploads = append(uploads, filename)
}
if !conf.UploadNSFW() {
nd := get.NsfwDetector()
containsNSFW := false
for _, filename := range uploads {
labels, err := nd.File(filename)
if err != nil {
log.Debug(err)
continue
}
if labels.IsSafe() {
continue
}
log.Infof("nsfw: %s might be offensive", clean.Log(filename))
containsNSFW = true
}
if containsNSFW {
for _, filename := range uploads {
if err := os.Remove(filename); err != nil {
log.Errorf("nsfw: could not delete %s", clean.Log(filename))
}
}
Abort(c, http.StatusForbidden, i18n.ErrOffensiveUpload)
return
}
}
elapsed := int(time.Since(start).Seconds())
msg := i18n.Msg(i18n.MsgFilesUploadedIn, uploaded, elapsed)
log.Info(msg)
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
})
}

View file

@ -1,17 +0,0 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUpload(t *testing.T) {
t.Run("forbidden", func(t *testing.T) {
app, router, _ := NewApiTest()
Upload(router)
r := PerformRequest(app, "POST", "/api/v1/upload/xxx")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View file

@ -0,0 +1,119 @@
package api
import (
"net/http"
"path"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/clean"
)
// UploadUserAvatar updates the avatar image of the currently authenticated user.
//
// POST /api/v1/users/:uid/avatar
func UploadUserAvatar(router *gin.RouterGroup) {
router.POST("/users/:uid/avatar", func(c *gin.Context) {
conf := get.Config()
if conf.Demo() || conf.DisableSettings() {
AbortForbidden(c)
return
}
s := AuthAny(c, acl.ResourceUsers, acl.Permissions{acl.ActionManage, acl.AccessOwn})
if s.Abort(c) {
return
}
uid := clean.UID(c.Param("uid"))
// Users may only change their own avatar.
if s.User().UserUID != uid {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "user uid does not match"}, s.RefID)
AbortForbidden(c)
return
}
f, err := c.MultipartForm()
if err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
files := f.File["files"]
if len(files) != 1 {
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
uploadDir, err := conf.UserUploadPath(s.UserUID, "")
if err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to create folder", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
file := files[0]
// Uploaded images must be JPEGs with a maximum file size of 10 MB.
if file.Size > 10000000 {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "file size exceeded"}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrFileTooLarge)
return
} else if fReader, fErr := file.Open(); fErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
} else if mimeType, mimeErr := mimetype.DetectReader(fReader); mimeErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
} else if !mimeType.Is(fs.MimeTypeJpeg) {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "only jpeg supported"}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrWrongFileType)
return
}
fileName := "avatar.jpg"
filePath := path.Join(uploadDir, fileName)
if err = c.SaveUploadedFile(file, filePath); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to save %s"}, s.RefID, clean.Log(filePath))
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
} else {
event.AuditInfo([]string{ClientIP(c), "session %s", "upload avatar", "saved as %s"}, s.RefID, clean.Log(filePath))
}
if mediaFile, mediaErr := photoprism.NewMediaFile(filePath); mediaErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrWrongFileType)
return
} else if err = mediaFile.CreateThumbnails(conf.ThumbCachePath(), false); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
} else if err = s.User().SetAvatar(mediaFile.Hash(), entity.SrcManual); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
}
// Clear the session cache, as it contains user information.
s.ClearCache()
log.Info(i18n.Msg(i18n.MsgFileUploaded))
c.JSON(http.StatusOK, entity.FindUserByUID(uid))
})
}

View file

@ -0,0 +1,22 @@
package api
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
)
func TestUploadUserAvatar(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
app, router, _ := NewApiTest()
adminUid := entity.Admin.UserUID
reqUrl := fmt.Sprintf("/api/v1/users/%s/avatar", adminUid)
UploadUserAvatar(router)
r := PerformRequestWithBody(app, "POST", reqUrl, "{foo:123}")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View file

@ -15,10 +15,10 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
)
// ChangePassword changes the password of the currently authenticated user.
// UpdateUserPassword changes the password of the currently authenticated user.
//
// PUT /api/v1/users/:uid/password
func ChangePassword(router *gin.RouterGroup) {
func UpdateUserPassword(router *gin.RouterGroup) {
router.PUT("/users/:uid/password", func(c *gin.Context) {
conf := get.Config()

View file

@ -14,7 +14,7 @@ import (
func TestChangePassword(t *testing.T) {
t.Run("NonExistentUser", func(t *testing.T) {
app, router, _ := NewApiTest()
ChangePassword(router)
UpdateUserPassword(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/users/xxx/password", `{}`)
assert.Equal(t, http.StatusForbidden, r.Code)
})
@ -23,7 +23,7 @@ func TestChangePassword(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
ChangePassword(router)
UpdateUserPassword(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
f := form.ChangePassword{
@ -43,7 +43,7 @@ func TestChangePassword(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
ChangePassword(router)
UpdateUserPassword(router)
oldPassword := "PleaseChange$42"
newPassword := "SoftwareDevelopmentIsAYoungProfession1234567890!@#$%^&*()_+[]{}|:<>?/.,"
@ -81,7 +81,7 @@ func TestChangePassword(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
ChangePassword(router)
UpdateUserPassword(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
f := form.ChangePassword{
@ -101,7 +101,7 @@ func TestChangePassword(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
ChangePassword(router)
UpdateUserPassword(router)
sessId := AuthenticateUser(app, router, "bob", "Bobbob123!")
f := form.ChangePassword{
@ -121,7 +121,7 @@ func TestChangePassword(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
ChangePassword(router)
UpdateUserPassword(router)
sessId := AuthenticateUser(app, router, "friend", "!Friend321")
f := form.ChangePassword{
@ -141,7 +141,7 @@ func TestChangePassword(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
ChangePassword(router)
UpdateUserPassword(router)
sessId := AuthenticateUser(app, router, "bob", "Bobbob123!")
f := form.ChangePassword{

View file

@ -0,0 +1,82 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
)
// UpdateUser updates the profile information of the currently authenticated user.
//
// PUT /api/v1/users/:uid
func UpdateUser(router *gin.RouterGroup) {
router.PUT("/users/:uid", func(c *gin.Context) {
conf := get.Config()
if conf.Demo() || conf.DisableSettings() {
AbortForbidden(c)
return
}
s := AuthAny(c, acl.ResourceUsers, acl.Permissions{acl.ActionManage, acl.AccessOwn, acl.ActionUpdate})
if s.Abort(c) {
return
}
uid := clean.UID(c.Param("uid"))
m := entity.FindUserByUID(uid)
if m == nil {
Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
return
}
// 1) Init form with model values
f, err := form.NewUser(m)
if err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
// 2) Update form with values from request
if err = c.BindJSON(&f); err != nil {
log.Error(err)
AbortBadRequest(c)
return
}
// 3) Save model with values from form
if err = m.SaveForm(f); err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
// Clear the session cache, as it contains user information.
s.ClearCache()
event.SuccessMsg(i18n.MsgChangesSaved)
m = entity.FindUserByUID(uid)
if m == nil {
AbortEntityNotFound(c)
return
}
c.JSON(http.StatusOK, m)
})
}

View file

@ -0,0 +1,36 @@
package api
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
)
func TestUpdateUser(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
UpdateUser(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
adminUid := entity.Admin.UserUID
reqUrl := fmt.Sprintf("/api/v1/users/%s", adminUid)
t.Logf("Request URL: %s", reqUrl)
r := AuthenticatedRequestWithBody(app, "PUT", reqUrl, "{Email:\"admin@example.com\",Details:{Location:\"WebStorm\"}}", sessId)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("Forbidden", func(t *testing.T) {
app, router, _ := NewApiTest()
adminUid := entity.Admin.UserUID
reqUrl := fmt.Sprintf("/api/v1/users/%s", adminUid)
UpdateUser(router)
r := PerformRequestWithBody(app, "PUT", reqUrl, "{foo:123}")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View file

@ -0,0 +1,245 @@
package api
import (
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/dustin/go-humanize/english"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/clean"
)
// UploadUserFiles adds files to the user upload folder, from where they can be moved and indexed.
//
// POST /users/:uid/upload/:token
func UploadUserFiles(router *gin.RouterGroup) {
router.POST("/users/:uid/upload/:token", func(c *gin.Context) {
conf := get.Config()
if conf.ReadOnly() || !conf.Settings().Features.Upload {
Abort(c, http.StatusForbidden, i18n.ErrReadOnly)
return
}
s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionManage, acl.ActionUpload})
if s.Abort(c) {
return
}
// Users may only upload their own files.
if s.User().UserUID != clean.UID(c.Param("uid")) {
AbortForbidden(c)
return
}
start := time.Now()
token := clean.Token(c.Param("token"))
f, err := c.MultipartForm()
if err != nil {
log.Errorf("upload: %s", err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
event.Publish("upload.start", event.Data{"time": start})
files := f.File["files"]
uploaded := len(files)
var uploads []string
uploadDir, err := conf.UserUploadPath(s.UserUID, s.RefID+token)
if err != nil {
log.Errorf("upload: failed to create storage folder (%s)", err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
for _, file := range files {
filename := path.Join(uploadDir, filepath.Base(file.Filename))
log.Debugf("upload: saving file %s", clean.Log(file.Filename))
if err := c.SaveUploadedFile(file, filename); err != nil {
log.Errorf("upload: failed saving file %s", clean.Log(filepath.Base(file.Filename)))
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
uploads = append(uploads, filename)
}
if !conf.UploadNSFW() {
nd := get.NsfwDetector()
containsNSFW := false
for _, filename := range uploads {
labels, err := nd.File(filename)
if err != nil {
log.Debug(err)
continue
}
if labels.IsSafe() {
continue
}
log.Infof("nsfw: %s might be offensive", clean.Log(filename))
containsNSFW = true
}
if containsNSFW {
for _, filename := range uploads {
if err := os.Remove(filename); err != nil {
log.Errorf("nsfw: could not delete %s", clean.Log(filename))
}
}
Abort(c, http.StatusForbidden, i18n.ErrOffensiveUpload)
return
}
}
elapsed := int(time.Since(start).Seconds())
msg := i18n.Msg(i18n.MsgFilesUploadedIn, uploaded, elapsed)
log.Info(msg)
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
})
}
// ProcessUserUpload triggers processing once all files have been uploaded.
//
// PUT /users/:uid/upload/:token
func ProcessUserUpload(router *gin.RouterGroup) {
router.PUT("/users/:uid/upload/:token", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionManage, acl.ActionUpload})
if s.Abort(c) {
return
}
// Users may only upload their own files.
if s.User().UserUID != clean.UID(c.Param("uid")) {
AbortForbidden(c)
return
}
conf := get.Config()
if conf.ReadOnly() || !conf.Settings().Features.Import {
AbortFeatureDisabled(c)
return
}
start := time.Now()
var f form.UploadOptions
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
token := clean.Token(c.Param("token"))
uploadPath, err := conf.UserUploadPath(s.UserUID, s.RefID+token)
if err != nil {
log.Errorf("upload: failed to create storage folder (%s)", err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
imp := get.Import()
var destFolder string
if destFolder = s.User().UploadPath; destFolder == "" {
destFolder = conf.ImportDest()
}
// Move uploaded files to the destination folder.
event.InfoMsg(i18n.MsgProcessingUpload)
opt := photoprism.ImportOptionsUpload(uploadPath, destFolder)
// Add imported files to albums if allowed.
if len(f.Albums) > 0 &&
acl.Resources.AllowAny(acl.ResourceAlbums, s.User().AclRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
log.Debugf("upload: adding files to album %s", clean.Log(strings.Join(f.Albums, " and ")))
opt.Albums = f.Albums
}
// Set user UID if known.
if s.UserUID != "" {
opt.UserUID = s.UserUID
}
// Start import.
imported := imp.Start(opt)
// Delete empty import directory.
if fs.DirIsEmpty(uploadPath) {
if err := os.Remove(uploadPath); err != nil {
log.Errorf("upload: failed deleting empty folder %s: %s", clean.Log(uploadPath), err)
} else {
log.Infof("upload: deleted empty folder %s", clean.Log(uploadPath))
}
}
// Update moments if files have been imported.
if n := len(imported); n == 0 {
log.Infof("upload: no new files imported", clean.Log(uploadPath))
} else {
log.Infof("upload: imported %s", english.Plural(n, "file", "files"))
if moments := get.Moments(); moments == nil {
log.Warnf("upload: moments service not set - possible bug")
} else if err := moments.Start(); err != nil {
log.Warnf("moments: %s", err)
}
}
elapsed := int(time.Since(start).Seconds())
// Show success message.
msg := i18n.Msg(i18n.MsgUploadProcessed)
event.Success(msg)
event.Publish("import.completed", event.Data{"path": uploadPath, "seconds": elapsed})
event.Publish("index.completed", event.Data{"path": uploadPath, "seconds": elapsed})
for _, uid := range f.Albums {
PublishAlbumEvent(EntityUpdated, uid, c)
}
// Update the user interface.
UpdateClientConfig()
// Update album, label, and subject cover thumbs.
if err := query.UpdateCovers(); err != nil {
log.Warnf("upload: %s (update covers)", err)
}
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
})
}

View file

@ -0,0 +1,23 @@
package api
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
)
func TestUploadUserFiles(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
app, router, _ := NewApiTest()
adminUid := entity.Admin.UserUID
reqUrl := fmt.Sprintf("/api/v1/users/%s/upload/abc123456789", adminUid)
// t.Logf("Request URL: %s", reqUrl)
UploadUserFiles(router)
r := PerformRequestWithBody(app, "POST", reqUrl, "{foo:123}")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View file

@ -10,6 +10,8 @@ import (
"runtime"
"sync"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
@ -87,6 +89,18 @@ func (c *Config) CreateDirectories() error {
return createError(c.StoragePath(), err)
}
if c.FilesPath() == "" {
return notFoundError("files")
} else if err := os.MkdirAll(c.FilesPath(), os.ModePerm); err != nil {
return createError(c.FilesPath(), err)
}
if c.UsersPath() == "" {
return notFoundError("users")
} else if err := os.MkdirAll(c.UsersPath(), os.ModePerm); err != nil {
return createError(c.UsersPath(), err)
}
if c.CmdCachePath() == "" {
return notFoundError("cmd cache")
} else if err := os.MkdirAll(c.CmdCachePath(), os.ModePerm); err != nil {
@ -296,6 +310,72 @@ func (c *Config) SidecarWritable() bool {
return !c.ReadOnly() || c.SidecarPathIsAbs()
}
// FilesPath returns the storage base path for files that should not be indexed.
func (c *Config) FilesPath() string {
// Set default.
if c.options.FilesPath == "" {
c.options.FilesPath = filepath.Join(c.StoragePath(), "files")
}
return c.options.FilesPath
}
// FilePath returns the file storage path based on the hash provided.
func (c *Config) FilePath(fileHash string) string {
if !rnd.IsHex(fileHash) || len(fileHash) < 4 {
return ""
}
dir := filepath.Join(c.FilesPath(), fileHash[0:1], fileHash[1:2], fileHash[2:3])
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return ""
}
return filepath.Join(dir, fileHash)
}
// UsersPath returns the storage base path for user assets like
// avatar images and other media that should not be indexed.
func (c *Config) UsersPath() string {
// Set default.
if c.options.UsersPath == "" {
c.options.UsersPath = filepath.Join(c.StoragePath(), "users")
}
return c.options.UsersPath
}
// UserPath returns the storage path for user assets.
func (c *Config) UserPath(userUid string) string {
if !rnd.IsUID(userUid, 0) {
return ""
}
dir := filepath.Join(c.UsersPath(), userUid)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return ""
}
return dir
}
// UserUploadPath returns the upload path for the specified user.
func (c *Config) UserUploadPath(userUid, token string) (string, error) {
if !rnd.IsUID(userUid, 0) {
return "", fmt.Errorf("invalid uid")
}
dir := filepath.Join(c.UserPath(userUid), "upload", clean.Token(token))
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return "", err
}
return dir, nil
}
// TempPath returns the cached temporary directory name e.g. for uploads and downloads.
func (c *Config) TempPath() string {
// Return cached value?

View file

@ -23,6 +23,53 @@ func TestConfig_SidecarPath(t *testing.T) {
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/sidecar", c.SidecarPath())
}
func TestConfig_FilePath(t *testing.T) {
c := NewConfig(CliTestContext())
t.Run("Valid", func(t *testing.T) {
s := c.FilePath("c476503628b4543c9ef97d69a6daa700b05d19bc")
assert.True(t, strings.HasSuffix(s, "/c/4/7/c476503628b4543c9ef97d69a6daa700b05d19bc"))
})
t.Run("InvalidHash", func(t *testing.T) {
assert.Equal(t, "", c.FilePath("YE"))
})
}
func TestConfig_UsersPath(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.UsersPath(), "testdata/users")
}
func TestConfig_UserPath(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.UserPath(""))
assert.Equal(t, "", c.UserPath("etaetyget"))
assert.Contains(t, c.UserPath("urjult03ceelhw6k"), "testdata/users/urjult03ceelhw6k")
}
func TestConfig_UserUploadPath(t *testing.T) {
c := NewConfig(CliTestContext())
if dir, err := c.UserUploadPath("", ""); err == nil {
t.Error("error expected")
} else {
assert.Equal(t, "", dir)
}
if dir, err := c.UserUploadPath("etaetyget", ""); err == nil {
t.Error("error expected")
} else {
assert.Equal(t, "", dir)
}
if dir, err := c.UserUploadPath("urjult03ceelhw6k", ""); err != nil {
t.Fatal(err)
} else {
assert.Contains(t, dir, "testdata/users/urjult03ceelhw6k/upload")
}
if dir, err := c.UserUploadPath("urjult03ceelhw6k", "foo"); err != nil {
t.Fatal(err)
} else {
assert.Contains(t, dir, "testdata/users/urjult03ceelhw6k/upload/foo")
}
}
func TestConfig_SidecarPathIsAbs(t *testing.T) {
c := NewConfig(CliTestContext())

View file

@ -140,6 +140,11 @@ var Flags = CliFlags{
Usage: "custom relative or absolute sidecar `PATH`*optional*",
EnvVar: "PHOTOPRISM_SIDECAR_PATH",
}}, {
Flag: cli.StringFlag{
Name: "users-path",
Usage: "custom users storage `PATH`*optional*",
EnvVar: "PHOTOPRISM_USERS_PATH",
}}, {
Flag: cli.StringFlag{
Name: "backup-path, ba",
Usage: "custom backup `PATH` for index backup files*optional*",

View file

@ -45,6 +45,8 @@ type Options struct {
ResolutionLimit int `yaml:"ResolutionLimit" json:"ResolutionLimit" flag:"resolution-limit"`
StoragePath string `yaml:"StoragePath" json:"-" flag:"storage-path"`
SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"`
FilesPath string `yaml:"FilesPath" json:"-" flag:"files-path"`
UsersPath string `yaml:"UsersPath" json:"-" flag:"users-path"`
BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"`
CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"`
ImportPath string `yaml:"ImportPath" json:"-" flag:"import-path"`

View file

@ -48,6 +48,8 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// Other paths.
{"storage-path", c.StoragePath()},
{"sidecar-path", c.SidecarPath()},
{"files-path", c.FilesPath()},
{"users-path", c.UsersPath()},
{"albums-path", c.AlbumsPath()},
{"backup-path", c.BackupPath()},
{"cache-path", c.CachePath()},

View file

@ -158,6 +158,11 @@ func (m *Session) Cache() {
m.CacheDuration(sessionCacheExpiration)
}
// ClearCache deletes the session from the cache.
func (m *Session) ClearCache() {
DeleteFromSessionCache(m.ID)
}
// Create new entity in the database.
func (m *Session) Create() (err error) {
if err = Db().Create(m).Error; err == nil && rnd.IsSessionID(m.ID) {

View file

@ -8,6 +8,8 @@ import (
"strings"
"time"
"github.com/ulule/deepcopier"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
@ -66,8 +68,8 @@ type User struct {
ResetToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
PreviewToken string `gorm:"type:VARBINARY(64);column:preview_token;" json:"-" yaml:"-"`
DownloadToken string `gorm:"type:VARBINARY(64);column:download_token;" json:"-" yaml:"-"`
Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb,omitempty" yaml:"Thumb,omitempty"`
ThumbSrc string `gorm:"type:VARBINARY(8);default:'';" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb" yaml:"Thumb,omitempty"`
ThumbSrc string `gorm:"type:VARBINARY(8);default:'';" json:"ThumbSrc" yaml:"ThumbSrc,omitempty"`
RefID string `gorm:"type:VARBINARY(16);" json:"-" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
@ -764,3 +766,42 @@ func (m *User) RedeemToken(token string) (n int) {
return n
}
// SaveForm updates the entity using form data and stores it in the database.
func (m *User) SaveForm(f form.User) error {
if m.UserName == "" || m.ID <= 0 {
return fmt.Errorf("system users cannot be updated")
}
if err := deepcopier.Copy(m.UserDetails).From(f.UserDetails); err != nil {
return err
}
if n := clean.Name(f.DisplayName); n != "" && n != m.DisplayName {
m.DisplayName = n
}
if email := clean.Email(f.UserEmail); email != "" && email != m.UserEmail {
m.UserEmail = email
m.VerifiedAt = nil
m.VerifyToken = GenerateToken()
}
return m.Save()
}
// SetAvatar updates the user avatar image.
func (m *User) SetAvatar(thumb, thumbSrc string) error {
if m.UserName == "" || m.ID <= 0 {
return fmt.Errorf("system user avatars cannot be changed")
}
if SrcPriority[thumbSrc] < SrcPriority[m.ThumbSrc] && m.Thumb != "" {
return fmt.Errorf("no permission to change avatar")
}
m.Thumb = thumb
m.ThumbSrc = thumbSrc
return m.Updates(Values{"Thumb": m.Thumb, "ThumbSrc": m.ThumbSrc})
}

View file

@ -16,36 +16,38 @@ const (
// UserDetails represents user profile information.
type UserDetails struct {
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"UserUID"`
SubjUID string `gorm:"type:VARBINARY(42);index;" json:"SubjUID,omitempty" yaml:"SubjUID,omitempty"`
SubjSrc string `gorm:"type:VARBINARY(8);default:'';" json:"SubjSrc,omitempty" yaml:"SubjSrc,omitempty"`
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID,omitempty" yaml:"-"`
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc,omitempty" yaml:"PlaceSrc,omitempty"`
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID,omitempty" yaml:"CellID,omitempty"`
IdURL string `gorm:"type:VARBINARY(512);column:id_url;" json:"IdURL,omitempty" yaml:"IdURL,omitempty"`
AvatarURL string `gorm:"type:VARBINARY(512);column:avatar_url" json:"AvatarURL,omitempty" yaml:"AvatarURL,omitempty"`
SiteURL string `gorm:"type:VARBINARY(512);column:site_url" json:"SiteURL,omitempty" yaml:"SiteURL,omitempty"`
FeedURL string `gorm:"type:VARBINARY(512);column:feed_url" json:"FeedURL,omitempty" yaml:"FeedURL,omitempty"`
UserGender string `gorm:"size:16;" json:"Gender,omitempty" yaml:"Gender,omitempty"`
NamePrefix string `gorm:"size:32;" json:"NamePrefix,omitempty" yaml:"NamePrefix,omitempty"`
GivenName string `gorm:"size:64;" json:"GivenName,omitempty" yaml:"GivenName,omitempty"`
MiddleName string `gorm:"size:64;" json:"MiddleName,omitempty" yaml:"MiddleName,omitempty"`
FamilyName string `gorm:"size:64;" json:"FamilyName,omitempty" yaml:"FamilyName,omitempty"`
NameSuffix string `gorm:"size:32;" json:"NameSuffix,omitempty" yaml:"NameSuffix,omitempty"`
NickName string `gorm:"size:64;" json:"NickName,omitempty" yaml:"NickName,omitempty"`
UserPhone string `gorm:"size:32;" json:"Phone,omitempty" yaml:"Phone,omitempty"`
UserAddress string `gorm:"size:512;" json:"Address,omitempty" yaml:"Address,omitempty"`
UserCountry string `gorm:"type:VARBINARY(2);" json:"Country,omitempty" yaml:"Country,omitempty"`
UserBio string `gorm:"size:512;" json:"Bio,omitempty" yaml:"Bio,omitempty"`
JobTitle string `gorm:"size:64;" json:"JobTitle,omitempty" yaml:"JobTitle,omitempty"`
Department string `gorm:"size:128;" json:"Department,omitempty" yaml:"Department,omitempty"`
Company string `gorm:"size:128;" json:"Company,omitempty" yaml:"Company,omitempty"`
CompanyURL string `gorm:"type:VARBINARY(512);column:company_url" json:"CompanyURL,omitempty" yaml:"CompanyURL,omitempty"`
BirthYear int `gorm:"default:-1;" json:"BirthYear,omitempty" yaml:"BirthYear,omitempty"`
BirthMonth int `gorm:"default:-1;" json:"BirthMonth,omitempty" yaml:"BirthMonth,omitempty"`
BirthDay int `gorm:"default:-1;" json:"BirthDay,omitempty" yaml:"BirthDay,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"-"`
IdURL string `gorm:"type:VARBINARY(512);column:id_url;" json:"IdURL,omitempty" yaml:"IdURL,omitempty"`
SubjUID string `gorm:"type:VARBINARY(42);index;" json:"SubjUID,omitempty" yaml:"SubjUID,omitempty"`
SubjSrc string `gorm:"type:VARBINARY(8);default:'';" json:"-" yaml:"SubjSrc,omitempty"`
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"-" yaml:"-"`
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"-" yaml:"PlaceSrc,omitempty"`
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"-" yaml:"CellID,omitempty"`
BirthYear int `gorm:"default:-1;" json:"BirthYear" yaml:"BirthYear,omitempty"`
BirthMonth int `gorm:"default:-1;" json:"BirthMonth" yaml:"BirthMonth,omitempty"`
BirthDay int `gorm:"default:-1;" json:"BirthDay" yaml:"BirthDay,omitempty"`
NamePrefix string `gorm:"size:32;" json:"NamePrefix" yaml:"NamePrefix,omitempty"`
GivenName string `gorm:"size:64;" json:"GivenName" yaml:"GivenName,omitempty"`
MiddleName string `gorm:"size:64;" json:"MiddleName" yaml:"MiddleName,omitempty"`
FamilyName string `gorm:"size:64;" json:"FamilyName" yaml:"FamilyName,omitempty"`
NameSuffix string `gorm:"size:32;" json:"NameSuffix" yaml:"NameSuffix,omitempty"`
NickName string `gorm:"size:64;" json:"NickName" yaml:"NickName,omitempty"`
UserGender string `gorm:"size:16;" json:"Gender" yaml:"Gender,omitempty"`
UserBio string `gorm:"size:512;" json:"Bio" yaml:"Bio,omitempty"`
UserLocation string `gorm:"size:512;" json:"Location" yaml:"Location,omitempty"`
UserCountry string `gorm:"type:VARBINARY(2);" json:"Country" yaml:"Country,omitempty"`
UserPhone string `gorm:"size:32;" json:"Phone" yaml:"Phone,omitempty"`
SiteURL string `gorm:"type:VARBINARY(512);column:site_url" json:"SiteURL" yaml:"SiteURL,omitempty"`
ProfileURL string `gorm:"type:VARBINARY(512);column:profile_url" json:"ProfileURL" yaml:"ProfileURL,omitempty"`
FeedURL string `gorm:"type:VARBINARY(512);column:feed_url" json:"FeedURL,omitempty" yaml:"FeedURL,omitempty"`
AvatarURL string `gorm:"type:VARBINARY(512);column:avatar_url" json:"AvatarURL,omitempty" yaml:"AvatarURL,omitempty"`
OrgName string `gorm:"size:128;" json:"OrgName" yaml:"OrgName,omitempty"`
OrgTitle string `gorm:"size:64;" json:"OrgTitle" yaml:"OrgTitle,omitempty"`
OrgEmail string `gorm:"size:255;index;" json:"OrgEmail" yaml:"OrgEmail,omitempty"`
OrgPhone string `gorm:"size:32;" json:"OrgPhone" yaml:"OrgPhone,omitempty"`
OrgURL string `gorm:"type:VARBINARY(512);column:org_url" json:"OrgURL" yaml:"OrgURL,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
}
// TableName returns the entity table name.

View file

@ -735,3 +735,49 @@ func TestUser_SharedUIDs(t *testing.T) {
assert.Equal(t, UIDs{"at9lxuqxpogaaba9"}, result)
})
}
func TestUser_SaveForm(t *testing.T) {
t.Run("UnknownUser", func(t *testing.T) {
frm, err := form.NewUser(UnknownUser)
assert.NoError(t, err)
err = UnknownUser.SaveForm(frm)
assert.Error(t, err)
})
t.Run("Admin", func(t *testing.T) {
frm, err := form.NewUser(Admin)
assert.NoError(t, err)
frm.UserEmail = "admin@example.com"
frm.UserDetails.UserLocation = "GoLand"
err = Admin.SaveForm(frm)
assert.NoError(t, err)
assert.Equal(t, "admin@example.com", Admin.UserEmail)
assert.Equal(t, "GoLand", Admin.Details().UserLocation)
m := FindUserByUID(Admin.UserUID)
assert.Equal(t, "admin@example.com", m.UserEmail)
assert.Equal(t, "GoLand", m.Details().UserLocation)
})
}
func TestUser_SetAvatar(t *testing.T) {
t.Run("Visitor", func(t *testing.T) {
err := Visitor.SetAvatar("ebfc0aea7d3fd018b5fff57c76806b35181855ed", SrcManual)
assert.Error(t, err)
})
t.Run("UnknownUser", func(t *testing.T) {
err := UnknownUser.SetAvatar("ebfc0aea7d3fd018b5fff57c76806b35181855ed", SrcManual)
assert.Error(t, err)
})
t.Run("Admin", func(t *testing.T) {
err := Admin.SetAvatar("ebfc0aea7d3fd018b5fff57c76806b35181855ed", SrcManual)
assert.NoError(t, err)
assert.Equal(t, "ebfc0aea7d3fd018b5fff57c76806b35181855ed", Admin.Thumb)
assert.Equal(t, SrcManual, Admin.ThumbSrc)
m := FindUserByUID(Admin.UserUID)
assert.Equal(t, "ebfc0aea7d3fd018b5fff57c76806b35181855ed", m.Thumb)
assert.Equal(t, SrcManual, m.ThumbSrc)
})
}

View file

@ -104,10 +104,10 @@ class auth_users_details {
varbinary(42) place_id
varbinary(8) place_src
varbinary(42) cell_id
varbinary(512) id_url
varbinary(512) avatar_url
varbinary(512) site_url
varbinary(512) feed_url
varchar(512) user_bio
varchar(32) user_phone
varchar(512) user_location
varbinary(2) user_country
varchar(16) user_gender
varchar(32) name_prefix
varchar(64) given_name
@ -115,14 +115,13 @@ class auth_users_details {
varchar(64) family_name
varchar(32) name_suffix
varchar(64) nick_name
varchar(32) user_phone
varchar(512) user_address
varbinary(2) user_country
varchar(512) user_bio
varchar(64) job_title
varchar(128) department
varchar(128) company
varbinary(512) company_url
varchar(64) org_title
varchar(128) org_name
varbinary(512) org_url
varbinary(512) id_url
varbinary(512) site_url
varbinary(512) feed_url
varbinary(512) avatar_url
int(11) birth_year
int(11) birth_month
int(11) birth_day

View file

@ -158,10 +158,10 @@ CREATE TABLE `auth_users_details` (
`place_id` varbinary(42) DEFAULT 'zz',
`place_src` varbinary(8) DEFAULT NULL,
`cell_id` varbinary(42) DEFAULT 'zz',
`id_url` varbinary(512) DEFAULT NULL,
`avatar_url` varbinary(512) DEFAULT NULL,
`site_url` varbinary(512) DEFAULT NULL,
`feed_url` varbinary(512) DEFAULT NULL,
`user_bio` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_phone` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_location` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_country` varbinary(2) DEFAULT NULL,
`user_gender` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`name_prefix` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`given_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
@ -169,14 +169,13 @@ CREATE TABLE `auth_users_details` (
`family_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`name_suffix` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`nick_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_phone` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_address` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_country` varbinary(2) DEFAULT NULL,
`user_bio` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`job_title` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`department` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`company` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`company_url` varbinary(512) DEFAULT NULL,
`org_title` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`org_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`org_url` varbinary(512) DEFAULT NULL,
`id_url` varbinary(512) DEFAULT NULL,
`site_url` varbinary(512) DEFAULT NULL,
`feed_url` varbinary(512) DEFAULT NULL,
`avatar_url` varbinary(512) DEFAULT NULL,
`birth_year` int(11) DEFAULT -1,
`birth_month` int(11) DEFAULT -1,
`birth_day` int(11) DEFAULT -1,

View file

@ -18,6 +18,7 @@ const (
SrcMeta = "meta" // Prio 16
SrcXmp = "xmp" // Prio 32
SrcManual = "manual" // Prio 64
SrcAdmin = "admin" // Prio 128
)
// SrcString returns a source string for logging.
@ -43,4 +44,5 @@ var SrcPriority = Priorities{
SrcMeta: 16,
SrcXmp: 32,
SrcManual: 64,
SrcAdmin: 128,
}

View file

@ -0,0 +1,5 @@
package form
type UploadOptions struct {
Albums []string `json:"albums"`
}

View file

@ -1,6 +1,7 @@
package form
import (
"github.com/ulule/deepcopier"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/pkg/clean"
@ -8,17 +9,24 @@ import (
// User represents a user account form.
type User struct {
UserName string `json:"Name" yaml:"Name,omitempty"`
UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"`
BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
Password string `json:"Password,omitempty" yaml:"Password,omitempty"`
UserName string `json:"Name" yaml:"Name,omitempty"`
UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"`
BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
Password string `json:"Password,omitempty" yaml:"Password,omitempty"`
UserDetails UserDetails `json:"Details"`
}
// NewUser creates a new user account form.
func NewUser(m interface{}) (f User, err error) {
err = deepcopier.Copy(m).To(&f)
return f, err
}
// NewUserFromCli creates a new form with values from a CLI context.

View file

@ -0,0 +1,27 @@
package form
// UserDetails represents a user details form.
type UserDetails struct {
BirthYear int `json:"BirthYear"`
BirthMonth int `json:"BirthMonth"`
BirthDay int `json:"BirthDay"`
NamePrefix string `json:"NamePrefix"`
GivenName string `json:"GivenName"`
MiddleName string `json:"MiddleName"`
FamilyName string `json:"FamilyName"`
NameSuffix string `json:"NameSuffix"`
NickName string `json:"NickName"`
UserGender string `json:"Gender"`
UserBio string `json:"Bio"`
UserLocation string `json:"Location"`
UserCountry string `json:"Country"`
UserPhone string `json:"Phone"`
SiteURL string `json:"SiteURL"`
ProfileURL string `json:"ProfileURL"`
FeedURL string `json:"FeedURL"`
OrgName string `json:"OrgName"`
OrgTitle string `json:"OrgTitle"`
OrgEmail string `json:"OrgEmail"`
OrgPhone string `json:"OrgPhone"`
OrgURL string `json:"OrgURL"`
}

View file

@ -14,6 +14,19 @@ func TestUser(t *testing.T) {
assert.Equal(t, "passwd", form.Password)
}
func TestNewUser(t *testing.T) {
val := &User{UserName: "foobar", UserEmail: "test@test.com", Password: "passwd"}
form, err := NewUser(val)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "foobar", form.UserName)
assert.Equal(t, "test@test.com", form.UserEmail)
assert.Equal(t, "passwd", form.Password)
}
func TestUser_Name(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
form := &User{UserName: "", UserEmail: "test@test.com", Password: "passwd"}

View file

@ -8,6 +8,8 @@ const (
ErrAlreadyExists
ErrNotFound
ErrFileNotFound
ErrFileTooLarge
ErrWrongFileType
ErrOriginalsEmpty
ErrSelectionNotFound
ErrEntityNotFound
@ -23,6 +25,7 @@ const (
ErrUnauthorized
ErrForbidden
ErrOffensiveUpload
ErrUploadFailed
ErrNoItemsSelected
ErrCreateFile
ErrCreateFolder
@ -72,7 +75,10 @@ const (
MsgSubjectDeleted
MsgPersonSaved
MsgPersonDeleted
MsgFileUploaded
MsgFilesUploadedIn
MsgProcessingUpload
MsgUploadProcessed
MsgSelectionApproved
MsgSelectionArchived
MsgSelectionRestored
@ -92,6 +98,8 @@ var Messages = MessageMap{
ErrAlreadyExists: gettext("%s already exists"),
ErrNotFound: gettext("Not found"),
ErrFileNotFound: gettext("File not found"),
ErrFileTooLarge: gettext("File too large"),
ErrWrongFileType: gettext("Wrong file type"),
ErrOriginalsEmpty: gettext("Originals folder is empty"),
ErrSelectionNotFound: gettext("Selection not found"),
ErrEntityNotFound: gettext("Entity not found"),
@ -107,6 +115,7 @@ var Messages = MessageMap{
ErrUnauthorized: gettext("Please log in to your account"),
ErrForbidden: gettext("Permission denied"),
ErrOffensiveUpload: gettext("Upload might be offensive"),
ErrUploadFailed: gettext("Upload failed"),
ErrNoItemsSelected: gettext("No items selected"),
ErrCreateFile: gettext("RunFailed creating file, please check permissions"),
ErrCreateFolder: gettext("RunFailed creating folder, please check permissions"),
@ -157,7 +166,10 @@ var Messages = MessageMap{
MsgSubjectDeleted: gettext("Subject deleted"),
MsgPersonSaved: gettext("Person saved"),
MsgPersonDeleted: gettext("Person deleted"),
MsgFileUploaded: gettext("File uploaded"),
MsgFilesUploadedIn: gettext("%d files uploaded in %d s"),
MsgProcessingUpload: gettext("Processing upload..."),
MsgUploadProcessed: gettext("Upload has been processed"),
MsgSelectionApproved: gettext("Selection approved"),
MsgSelectionArchived: gettext("Selection archived"),
MsgSelectionRestored: gettext("Selection restored"),

View file

@ -5,6 +5,7 @@ type ImportOptions struct {
Albums []string
Path string
Move bool
NonBlocking bool
UserUID string
DestFolder string
RemoveDotFiles bool
@ -17,6 +18,7 @@ func ImportOptionsCopy(importPath, destFolder string) ImportOptions {
result := ImportOptions{
Path: importPath,
Move: false,
NonBlocking: false,
DestFolder: destFolder,
RemoveDotFiles: false,
RemoveExistingFiles: false,
@ -31,6 +33,22 @@ func ImportOptionsMove(importPath, destFolder string) ImportOptions {
result := ImportOptions{
Path: importPath,
Move: true,
NonBlocking: false,
DestFolder: destFolder,
RemoveDotFiles: true,
RemoveExistingFiles: true,
RemoveEmptyDirectories: true,
}
return result
}
// ImportOptionsUpload returns options for importing user uploads.
func ImportOptionsUpload(uploadPath, destFolder string) ImportOptions {
result := ImportOptions{
Path: uploadPath,
Move: true,
NonBlocking: true,
DestFolder: destFolder,
RemoveDotFiles: true,
RemoveExistingFiles: true,

View file

@ -12,7 +12,6 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)

View file

@ -67,6 +67,7 @@ func ThumbHashMap() (result HashMap, err error) {
entity.Label{}.TableName(),
entity.Marker{}.TableName(),
entity.Subject{}.TableName(),
entity.User{}.TableName(),
}
result = make(HashMap)

View file

@ -56,7 +56,12 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
// JSON-REST API Version 1
v1 := router.Group(conf.BaseUri(config.ApiUri))
{
// Global App Config.
// Authentication.
api.CreateSession(v1)
api.GetSession(v1)
api.DeleteSession(v1)
// Global Config.
api.GetConfigOptions(v1)
api.SaveConfigOptions(v1)
@ -65,13 +70,14 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetSettings(v1)
api.SaveSettings(v1)
// User Authentication.
api.ChangePassword(v1)
api.CreateSession(v1)
api.GetSession(v1)
api.DeleteSession(v1)
// Profile and Uploads.
api.UploadUserFiles(v1)
api.ProcessUserUpload(v1)
api.UploadUserAvatar(v1)
api.UpdateUserPassword(v1)
api.UpdateUser(v1)
// External Account Management.
// Service Accounts.
api.SearchServices(v1)
api.GetService(v1)
api.GetServiceFolders(v1)
@ -80,21 +86,24 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DeleteService(v1)
api.UpdateService(v1)
// Thumbs, Downloads, and Videos.
// Thumbnail Images.
api.GetThumb(v1)
api.GetDownload(v1)
// Video Streaming.
api.GetVideo(v1)
// Downloads.
api.GetDownload(v1)
api.ZipCreate(v1)
api.ZipDownload(v1)
// Index, Upload, and Import.
api.Upload(v1)
// Index and Import.
api.StartImport(v1)
api.CancelImport(v1)
api.StartIndexing(v1)
api.CancelIndexing(v1)
// Search & Organization.
// Photo Search and Organization.
api.SearchPhotos(v1)
api.SearchGeo(v1)
api.GetPhoto(v1)