PersonalSettingAvatar.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { storeToRefs } from 'pinia'
  4. import { computed, useTemplateRef } from 'vue'
  5. import CommonAvatar from '#shared/components/CommonAvatar/CommonAvatar.vue'
  6. import { NotificationTypes } from '#shared/components/CommonNotifications/types.ts'
  7. import { useNotifications } from '#shared/components/CommonNotifications/useNotifications.ts'
  8. import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
  9. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  10. import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
  11. import { useUserCurrentAvatarAddMutation } from '#shared/entities/user/current/graphql/mutations/userCurrentAvatarAdd.api.ts'
  12. import { useUserCurrentAvatarDeleteMutation } from '#shared/entities/user/current/graphql/mutations/userCurrentAvatarDelete.api.ts'
  13. import type {
  14. UserCurrentAvatarUpdatesSubscriptionVariables,
  15. UserCurrentAvatarUpdatesSubscription,
  16. Avatar,
  17. UserCurrentAvatarListQuery,
  18. } from '#shared/graphql/types.ts'
  19. import { getApolloClient } from '#shared/server/apollo/client.ts'
  20. import MutationHandler from '#shared/server/apollo/handler/MutationHandler.ts'
  21. import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
  22. import { useApplicationStore } from '#shared/stores/application.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 '#desktop/components/CommonButton/CommonButton.vue'
  30. import CommonDivider from '#desktop/components/CommonDivider/CommonDivider.vue'
  31. import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
  32. import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
  33. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  34. import { useBreadcrumb } from '../composables/useBreadcrumb.ts'
  35. import { useUserCurrentAvatarSelectMutation } from '../graphql/mutations/userCurrentAvatarSelect.api.ts'
  36. import {
  37. useUserCurrentAvatarListQuery,
  38. UserCurrentAvatarListDocument,
  39. } from '../graphql/queries/userCurrentAvatarList.api.ts'
  40. import { UserCurrentAvatarUpdatesDocument } from '../graphql/subscriptions/userCurrentAvatarUpdates.api.ts'
  41. import type { ApolloCache, NormalizedCacheObject } from '@apollo/client/core'
  42. const { user } = storeToRefs(useSessionStore())
  43. const { breadcrumbItems } = useBreadcrumb(__('Avatar'))
  44. const { notify } = useNotifications()
  45. const application = useApplicationStore()
  46. const apiUrl = String(application.config.api_path)
  47. const { isTouchDevice } = useTouchDevice()
  48. const avatarListQuery = new QueryHandler(useUserCurrentAvatarListQuery())
  49. const avatarListQueryResult = avatarListQuery.result()
  50. const avatarListQueryLoading = avatarListQuery.loading()
  51. avatarListQuery.subscribeToMore<
  52. UserCurrentAvatarUpdatesSubscriptionVariables,
  53. UserCurrentAvatarUpdatesSubscription
  54. >({
  55. document: UserCurrentAvatarUpdatesDocument,
  56. updateQuery: (prev, { subscriptionData }) => {
  57. if (!subscriptionData.data?.userCurrentAvatarUpdates.avatars) {
  58. return null as unknown as UserCurrentAvatarListQuery
  59. }
  60. return {
  61. userCurrentAvatarList:
  62. subscriptionData.data.userCurrentAvatarUpdates.avatars,
  63. }
  64. },
  65. })
  66. const currentAvatars = computed(() => {
  67. return avatarListQueryResult.value?.userCurrentAvatarList || []
  68. })
  69. const currentDefaultAvatar = computed(() => {
  70. return currentAvatars.value.find((avatar) => avatar.default)
  71. })
  72. const fileUploadElement = useTemplateRef('file-upload')
  73. const cameraFlyout = useFlyout({
  74. name: 'avatar-camera-capture',
  75. component: () =>
  76. import('../components/PersonalSettingAvatarCameraFlyout.vue'),
  77. })
  78. const cropImageFlyout = useFlyout({
  79. name: 'avatar-file-upload',
  80. component: () =>
  81. import('../components/PersonalSettingAvatarCropImageFlyout.vue'),
  82. })
  83. const modifyDefaultAvatarCache = (
  84. cache: ApolloCache<NormalizedCacheObject>,
  85. avatar: Avatar | undefined,
  86. newValue: boolean,
  87. ) => {
  88. if (!avatar) return
  89. cache.modify({
  90. id: cache.identify(avatar),
  91. fields: {
  92. default() {
  93. return newValue
  94. },
  95. },
  96. })
  97. }
  98. const storeAvatar = (image: ImageFileData) => {
  99. if (!image) return
  100. const addAvatarMutation = new MutationHandler(
  101. useUserCurrentAvatarAddMutation({
  102. variables: {
  103. images: {
  104. original: image,
  105. resized: {
  106. name: 'resized_avatar.png',
  107. type: 'image/png',
  108. content: image.content,
  109. },
  110. },
  111. },
  112. update: (cache, { data }) => {
  113. if (!data) return
  114. const { userCurrentAvatarAdd } = data
  115. if (!userCurrentAvatarAdd?.avatar) return
  116. const newIdPresent = currentAvatars.value.find((avatar) => {
  117. return avatar.id === userCurrentAvatarAdd.avatar?.id
  118. })
  119. if (newIdPresent) return
  120. modifyDefaultAvatarCache(cache, currentDefaultAvatar.value, false)
  121. let existingAvatars = cache.readQuery<UserCurrentAvatarListQuery>({
  122. query: UserCurrentAvatarListDocument,
  123. })
  124. existingAvatars = {
  125. ...existingAvatars,
  126. userCurrentAvatarList: [
  127. ...(existingAvatars?.userCurrentAvatarList || []),
  128. userCurrentAvatarAdd.avatar,
  129. ],
  130. }
  131. cache.writeQuery({
  132. query: UserCurrentAvatarListDocument,
  133. data: existingAvatars,
  134. })
  135. },
  136. }),
  137. {
  138. errorNotificationMessage: __('The avatar could not be uploaded.'),
  139. },
  140. )
  141. addAvatarMutation.send().then((data) => {
  142. if (data?.userCurrentAvatarAdd?.avatar) {
  143. if (user.value) {
  144. user.value.image = data.userCurrentAvatarAdd.avatar.imageHash
  145. }
  146. notify({
  147. id: 'avatar-upload-success',
  148. type: NotificationTypes.Success,
  149. message: __('Your avatar has been uploaded.'),
  150. })
  151. }
  152. })
  153. }
  154. const addAvatarByUpload = () => {
  155. fileUploadElement.value?.click()
  156. }
  157. const addAvatarByCamera = () => {
  158. cameraFlyout.open({
  159. onAvatarCaptured: (image: ImageFileData) => {
  160. storeAvatar(image)
  161. },
  162. })
  163. }
  164. const loadAvatar = async (input: HTMLInputElement | null) => {
  165. const files = input?.files
  166. if (!files) return
  167. const [avatar] = await convertFileList(files)
  168. cropImageFlyout.open({
  169. image: avatar,
  170. onImageCropped: (image: ImageFileData) => storeAvatar(image),
  171. })
  172. // Reset input value to allow selecting the same file again
  173. input.value = ''
  174. }
  175. const selectAvatar = (avatar: Avatar) => {
  176. // Update the cache already before the
  177. const { cache } = getApolloClient()
  178. const oldDefaultAvatar = currentDefaultAvatar.value
  179. modifyDefaultAvatarCache(cache, oldDefaultAvatar, false)
  180. modifyDefaultAvatarCache(cache, avatar, true)
  181. const accountAvatarSelectMutation = new MutationHandler(
  182. useUserCurrentAvatarSelectMutation(() => ({
  183. variables: { id: avatar.id },
  184. })),
  185. {
  186. errorNotificationMessage: __('The avatar could not be selected.'),
  187. },
  188. )
  189. accountAvatarSelectMutation
  190. .send()
  191. .then(() => {
  192. notify({
  193. id: 'avatar-select-success',
  194. type: NotificationTypes.Success,
  195. message: __('Your avatar has been changed.'),
  196. })
  197. })
  198. .catch(() => {
  199. // Reset the cache again if the mutation fails.
  200. modifyDefaultAvatarCache(cache, oldDefaultAvatar, true)
  201. modifyDefaultAvatarCache(cache, avatar, false)
  202. })
  203. }
  204. const deleteAvatar = (avatar: Avatar) => {
  205. const accountAvatarDeleteMutation = new MutationHandler(
  206. useUserCurrentAvatarDeleteMutation(() => ({
  207. variables: { id: avatar.id },
  208. update(cache) {
  209. if (avatar.default) {
  210. modifyDefaultAvatarCache(cache, currentAvatars.value[0], true)
  211. }
  212. cache.evict({ id: cache.identify(avatar) })
  213. cache.gc()
  214. },
  215. })),
  216. {
  217. errorNotificationMessage: __('The avatar could not be deleted.'),
  218. },
  219. )
  220. accountAvatarDeleteMutation.send().then(() => {
  221. notify({
  222. id: 'avatar-delete-success',
  223. type: NotificationTypes.Success,
  224. message: __('Your avatar has been deleted.'),
  225. })
  226. })
  227. }
  228. const { waitForVariantConfirmation } = useConfirmation()
  229. const confirmDeleteAvatar = async (avatar: Avatar) => {
  230. const confirmed = await waitForVariantConfirmation('delete')
  231. if (confirmed) deleteAvatar(avatar)
  232. }
  233. const avatarButtonClasses = [
  234. 'cursor-pointer',
  235. '-:outline-transparent',
  236. 'hover:-:outline-blue-900',
  237. 'rounded-full',
  238. 'outline',
  239. 'outline-3',
  240. 'focus:outline-blue-800',
  241. 'hover:focus:outline-blue-800',
  242. ]
  243. const activeAvatarButtonClass = (active: boolean) => {
  244. return {
  245. 'outline-blue-800 hover:outline-blue-800': active,
  246. }
  247. }
  248. </script>
  249. <template>
  250. <LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
  251. <CommonLoader :loading="avatarListQueryLoading">
  252. <div class="mb-4">
  253. <CommonLabel class="!mt-0.5 mb-1 !block"
  254. >{{ $t('Your avatar') }}
  255. </CommonLabel>
  256. <div class="rounded-lg bg-blue-200 dark:bg-gray-700">
  257. <div class="flex flex-row flex-wrap gap-2.5 p-2.5">
  258. <template v-for="avatar in currentAvatars" :key="avatar.id">
  259. <button
  260. v-if="avatar.initial && user"
  261. :aria-label="$t('Select this avatar')"
  262. :class="[
  263. ...avatarButtonClasses,
  264. activeAvatarButtonClass(avatar.default),
  265. ]"
  266. @click.stop="avatar.default ? void 0 : selectAvatar(avatar)"
  267. >
  268. <CommonUserAvatar
  269. :class="{ 'avatar-selected': avatar.default }"
  270. :entity="user"
  271. class="!flex border-neutral-100 dark:border-gray-900"
  272. size="large"
  273. initials-only
  274. personal
  275. no-indicator
  276. no-muted
  277. />
  278. </button>
  279. <div
  280. v-else-if="avatar.imageHash"
  281. class="group/avatar relative flex"
  282. >
  283. <button
  284. :aria-label="$t('Select this avatar')"
  285. :class="[
  286. ...avatarButtonClasses,
  287. activeAvatarButtonClass(avatar.default),
  288. ]"
  289. @click.stop="avatar.default ? void 0 : selectAvatar(avatar)"
  290. >
  291. <CommonAvatar
  292. :class="{ 'avatar-selected': avatar.default }"
  293. :image="`${apiUrl}/users/image/${avatar.imageHash}`"
  294. class="!flex border-neutral-100 dark:border-gray-900"
  295. size="large"
  296. >
  297. </CommonAvatar>
  298. </button>
  299. <CommonButton
  300. v-if="avatar.deletable"
  301. :aria-label="$t('Delete this avatar')"
  302. :class="{ 'opacity-0 transition-opacity': !isTouchDevice }"
  303. class="absolute -end-2 -top-1 text-white focus:opacity-100 group-hover/avatar:opacity-100"
  304. icon="x-lg"
  305. size="small"
  306. variant="remove"
  307. @click.stop="confirmDeleteAvatar(avatar)"
  308. />
  309. </div>
  310. </template>
  311. </div>
  312. <CommonDivider padding />
  313. <div class="w-full p-1 text-center">
  314. <input
  315. ref="file-upload"
  316. :accept="allowedImageTypesString()"
  317. aria-hidden="true"
  318. class="hidden"
  319. data-test-id="fileUploadInput"
  320. type="file"
  321. @change="loadAvatar(fileUploadElement)"
  322. />
  323. <CommonButton
  324. class="m-1"
  325. size="medium"
  326. prefix-icon="image"
  327. @click="addAvatarByUpload"
  328. >
  329. {{ $t('Upload') }}
  330. </CommonButton>
  331. <CommonButton
  332. class="m-1"
  333. size="medium"
  334. prefix-icon="camera"
  335. @click="addAvatarByCamera"
  336. >
  337. {{ $t('Camera') }}
  338. </CommonButton>
  339. </div>
  340. </div>
  341. </div>
  342. </CommonLoader>
  343. </LayoutContent>
  344. </template>