AccountAvatar.vue 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { reactive, shallowRef, watch, ref, computed } from 'vue'
  4. import { storeToRefs } from 'pinia'
  5. import { Cropper, type CropperResult } from 'vue-advanced-cropper'
  6. import 'vue-advanced-cropper/dist/style.css'
  7. import type { ImageFileData } from '@shared/utils/files'
  8. import { convertFileList } from '@shared/utils/files'
  9. import { useSessionStore } from '@shared/stores/session'
  10. import { useApplicationStore } from '@shared/stores/application'
  11. import {
  12. useNotifications,
  13. NotificationTypes,
  14. } from '@shared/components/CommonNotifications'
  15. import type UserError from '@shared/errors/UserError'
  16. import CommonUserAvatar from '@shared/components/CommonUserAvatar/CommonUserAvatar.vue'
  17. import CommonAvatar from '@shared/components/CommonAvatar/CommonAvatar.vue'
  18. import { MutationHandler, QueryHandler } from '@shared/server/apollo/handler'
  19. import type { AccountAvatarActiveQuery } from '@shared/graphql/types'
  20. import { useRouter } from 'vue-router'
  21. import { useHeader } from '@mobile/composables/useHeader'
  22. import useConfirmation from '@mobile/components/CommonConfirmation/composable'
  23. import CommonLoader from '@mobile/components/CommonLoader/CommonLoader.vue'
  24. import { useAccountAvatarActiveQuery } from '../avatar/graphql/queries/active.api'
  25. import { useAccountAvatarAddMutation } from '../avatar/graphql/mutations/add.api'
  26. import { useAccountAvatarDeleteMutation } from '../avatar/graphql/mutations/delete.api'
  27. const router = useRouter()
  28. const fileCameraInput = shallowRef<HTMLInputElement>()
  29. const fileGalleryInput = shallowRef<HTMLInputElement>()
  30. const avatarImage = shallowRef<ImageFileData>()
  31. const activeAvatarQuery = new QueryHandler(useAccountAvatarActiveQuery(), {
  32. errorNotificationMessage: __('The avatar could not be fetched.'),
  33. })
  34. const activeAvatar = ref<AccountAvatarActiveQuery['accountAvatarActive']>()
  35. activeAvatarQuery.watchOnResult((data) => {
  36. activeAvatar.value = data?.accountAvatarActive
  37. })
  38. const avatarLoading = activeAvatarQuery.loading()
  39. const state = reactive({
  40. image: activeAvatar.value?.imageResize || '',
  41. })
  42. watch(activeAvatar, (newValue) => {
  43. state.image = newValue?.imageResize || ''
  44. })
  45. const { user } = storeToRefs(useSessionStore())
  46. const avatarDeleteDisabled = computed(() => {
  47. return !activeAvatar.value?.deletable
  48. })
  49. const addAvatar = () => {
  50. if (!state.image) return
  51. if (!avatarImage.value) return
  52. const addAvatarMutation = new MutationHandler(
  53. useAccountAvatarAddMutation({
  54. variables: {
  55. images: {
  56. full: avatarImage.value?.content,
  57. resize: state.image,
  58. },
  59. },
  60. }),
  61. {
  62. errorNotificationMessage: __('The avatar could not be uploaded.'),
  63. },
  64. )
  65. const { notify, clearAllNotifications } = useNotifications()
  66. // Clear notifications to avoid duplicated error messages.
  67. clearAllNotifications()
  68. addAvatarMutation
  69. .send()
  70. .then((data) => {
  71. if (data?.accountAvatarAdd?.avatar) {
  72. activeAvatar.value = data.accountAvatarAdd.avatar
  73. avatarImage.value = undefined
  74. if (user.value) {
  75. user.value.image = activeAvatar.value?.imageResize
  76. }
  77. }
  78. })
  79. .catch((errors: UserError) => {
  80. notify({
  81. message: errors.generalErrors[0],
  82. type: NotificationTypes.Error,
  83. })
  84. })
  85. }
  86. const canRemoveAvatar = () => {
  87. if (!user.value) return false
  88. if (!activeAvatar.value?.id) return false
  89. if (!activeAvatar.value?.deletable) return false
  90. return true
  91. }
  92. const removeAvatar = () => {
  93. if (!canRemoveAvatar()) return
  94. if (!activeAvatar.value?.id) return
  95. const removeAvatarMutation = new MutationHandler(
  96. useAccountAvatarDeleteMutation({
  97. variables: { id: activeAvatar.value.id },
  98. }),
  99. {
  100. errorNotificationMessage: __('The avatar could not be deleted.'),
  101. },
  102. )
  103. removeAvatarMutation.send().then((data) => {
  104. if (data?.accountAvatarDelete?.success) {
  105. state.image = ''
  106. avatarImage.value = undefined
  107. activeAvatar.value = undefined
  108. // reset image value in user store
  109. if (user.value) {
  110. user.value.image = undefined
  111. }
  112. }
  113. })
  114. }
  115. const { showConfirmation } = useConfirmation()
  116. const confirmRemoveAvatar = async () => {
  117. if (!canRemoveAvatar()) return
  118. showConfirmation({
  119. heading: __('Do you really want to delete your current avatar?'),
  120. buttonTitle: __('Delete avatar'),
  121. buttonTextColorClass: 'text-red-bright',
  122. confirmCallback: removeAvatar,
  123. })
  124. }
  125. const saveButtonActive = computed(() => {
  126. if (state.image && avatarImage.value) return true
  127. return false
  128. })
  129. useHeader({
  130. title: __('Avatar'),
  131. backUrl: '/account',
  132. backTitle: __('Account'),
  133. actionTitle: __('Done'),
  134. onAction() {
  135. router.push('/account')
  136. },
  137. })
  138. const loadAvatar = async (input?: HTMLInputElement) => {
  139. const files = input?.files
  140. const [avatar] = await convertFileList(files)
  141. avatarImage.value = avatar
  142. }
  143. const imageCropped = (crop: CropperResult) => {
  144. if (!crop.canvas) return
  145. state.image = crop.canvas.toDataURL('image/png')
  146. }
  147. const cancelCropping = () => {
  148. avatarImage.value = undefined
  149. state.image = activeAvatar.value?.imageResize || ''
  150. }
  151. const application = useApplicationStore()
  152. const allowedImageTypes = computed(() => {
  153. if (!application.config['active_storage.web_image_content_types'])
  154. return 'image/*'
  155. const types = application.config[
  156. 'active_storage.web_image_content_types'
  157. ] as Array<string>
  158. return types.join(',')
  159. })
  160. </script>
  161. <template>
  162. <div v-if="user" class="px-4">
  163. <div class="flex flex-col items-center py-6">
  164. <CommonLoader :loading="avatarLoading">
  165. <CommonAvatar v-if="state.image" :image="state.image" size="xl" />
  166. <CommonUserAvatar v-else :entity="user" size="xl" personal />
  167. <div class="mt-4 flex w-full justify-center gap-2">
  168. <button
  169. class="w-full cursor-pointer rounded-xl bg-green py-2 px-3 text-base text-black"
  170. @click="fileGalleryInput?.click()"
  171. >
  172. {{ $t('Library') }}
  173. </button>
  174. <button
  175. class="w-full cursor-pointer rounded-xl bg-blue py-2 px-3 text-base text-black"
  176. @click="fileCameraInput?.click()"
  177. >
  178. {{ $t('Camera') }}
  179. </button>
  180. <button
  181. class="w-full rounded-xl bg-red-bright py-2 px-3 text-base text-black disabled:opacity-50"
  182. :class="{
  183. ['cursor-pointer']: !avatarDeleteDisabled,
  184. ['cursor-not-allowed']: avatarDeleteDisabled,
  185. }"
  186. :disabled="avatarDeleteDisabled"
  187. @click="confirmRemoveAvatar"
  188. >
  189. {{ $t('Delete') }}
  190. </button>
  191. </div>
  192. </CommonLoader>
  193. <input
  194. ref="fileGalleryInput"
  195. data-test-id="fileGalleryInput"
  196. type="file"
  197. class="hidden"
  198. aria-hidden="true"
  199. :accept="allowedImageTypes"
  200. @change="loadAvatar(fileGalleryInput)"
  201. />
  202. <input
  203. ref="fileCameraInput"
  204. data-test-id="fileCameraInput"
  205. type="file"
  206. class="hidden"
  207. aria-hidden="true"
  208. :accept="allowedImageTypes"
  209. capture="environment"
  210. @change="loadAvatar(fileCameraInput)"
  211. />
  212. <div
  213. v-if="avatarImage"
  214. class="flex w-full flex-col items-center justify-center"
  215. >
  216. <Cropper
  217. class="cropper mb-4 mt-4"
  218. :src="avatarImage.content"
  219. :stencil-props="{
  220. aspectRatio: 1 / 1,
  221. }"
  222. :transitions="false"
  223. background-class="!bg-black"
  224. foreground-class="!bg-black"
  225. @change="imageCropped"
  226. />
  227. <div class="flex w-full gap-2">
  228. <button
  229. class="w-full cursor-pointer rounded-xl py-2 px-3 text-base"
  230. @click="cancelCropping"
  231. >
  232. {{ $t('Cancel') }}
  233. </button>
  234. <button
  235. class="w-full cursor-pointer rounded-xl bg-yellow py-2 px-3 text-base text-black"
  236. :disabled="!saveButtonActive"
  237. @click="addAvatar"
  238. >
  239. {{ $t('Save') }}
  240. </button>
  241. </div>
  242. </div>
  243. </div>
  244. </div>
  245. </template>
  246. <style scoped lang="scss">
  247. .cropper {
  248. max-height: 250px;
  249. max-width: 400px;
  250. }
  251. </style>