PersonalSettingAvatar.vue 8.3 KB

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