123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
- <script setup lang="ts">
- import { storeToRefs } from 'pinia'
- import { computed, useTemplateRef } from 'vue'
- import CommonAvatar from '#shared/components/CommonAvatar/CommonAvatar.vue'
- import { NotificationTypes } from '#shared/components/CommonNotifications/types.ts'
- import { useNotifications } from '#shared/components/CommonNotifications/useNotifications.ts'
- import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
- import { useConfirmation } from '#shared/composables/useConfirmation.ts'
- import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
- import { useUserCurrentAvatarAddMutation } from '#shared/entities/user/current/graphql/mutations/userCurrentAvatarAdd.api.ts'
- import { useUserCurrentAvatarDeleteMutation } from '#shared/entities/user/current/graphql/mutations/userCurrentAvatarDelete.api.ts'
- import type {
- UserCurrentAvatarUpdatesSubscriptionVariables,
- UserCurrentAvatarUpdatesSubscription,
- Avatar,
- UserCurrentAvatarListQuery,
- } from '#shared/graphql/types.ts'
- import { getApolloClient } from '#shared/server/apollo/client.ts'
- import MutationHandler from '#shared/server/apollo/handler/MutationHandler.ts'
- import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
- import { useApplicationStore } from '#shared/stores/application.ts'
- import { useSessionStore } from '#shared/stores/session.ts'
- import type { ImageFileData } from '#shared/utils/files.ts'
- import {
- convertFileList,
- allowedImageTypesString,
- } from '#shared/utils/files.ts'
- import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
- import CommonDivider from '#desktop/components/CommonDivider/CommonDivider.vue'
- import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
- import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
- import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
- import { useBreadcrumb } from '../composables/useBreadcrumb.ts'
- import { useUserCurrentAvatarSelectMutation } from '../graphql/mutations/userCurrentAvatarSelect.api.ts'
- import {
- useUserCurrentAvatarListQuery,
- UserCurrentAvatarListDocument,
- } from '../graphql/queries/userCurrentAvatarList.api.ts'
- import { UserCurrentAvatarUpdatesDocument } from '../graphql/subscriptions/userCurrentAvatarUpdates.api.ts'
- import type { ApolloCache, NormalizedCacheObject } from '@apollo/client/core'
- const { user } = storeToRefs(useSessionStore())
- const { breadcrumbItems } = useBreadcrumb(__('Avatar'))
- const { notify } = useNotifications()
- const application = useApplicationStore()
- const apiUrl = String(application.config.api_path)
- const { isTouchDevice } = useTouchDevice()
- const avatarListQuery = new QueryHandler(useUserCurrentAvatarListQuery())
- const avatarListQueryResult = avatarListQuery.result()
- const avatarListQueryLoading = avatarListQuery.loading()
- avatarListQuery.subscribeToMore<
- UserCurrentAvatarUpdatesSubscriptionVariables,
- UserCurrentAvatarUpdatesSubscription
- >({
- document: UserCurrentAvatarUpdatesDocument,
- variables: {
- userId: user.value?.id || '',
- },
- updateQuery: (prev, { subscriptionData }) => {
- if (!subscriptionData.data?.userCurrentAvatarUpdates.avatars) {
- return null as unknown as UserCurrentAvatarListQuery
- }
- return {
- userCurrentAvatarList:
- subscriptionData.data.userCurrentAvatarUpdates.avatars,
- }
- },
- })
- const currentAvatars = computed(() => {
- return avatarListQueryResult.value?.userCurrentAvatarList || []
- })
- const currentDefaultAvatar = computed(() => {
- return currentAvatars.value.find((avatar) => avatar.default)
- })
- const fileUploadElement = useTemplateRef('file-upload')
- const cameraFlyout = useFlyout({
- name: 'avatar-camera-capture',
- component: () =>
- import('../components/PersonalSettingAvatarCameraFlyout.vue'),
- })
- const cropImageFlyout = useFlyout({
- name: 'avatar-file-upload',
- component: () =>
- import('../components/PersonalSettingAvatarCropImageFlyout.vue'),
- })
- const modifyDefaultAvatarCache = (
- cache: ApolloCache<NormalizedCacheObject>,
- avatar: Avatar | undefined,
- newValue: boolean,
- ) => {
- if (!avatar) return
- cache.modify({
- id: cache.identify(avatar),
- fields: {
- default() {
- return newValue
- },
- },
- })
- }
- const storeAvatar = (image: ImageFileData) => {
- if (!image) return
- const addAvatarMutation = new MutationHandler(
- useUserCurrentAvatarAddMutation({
- variables: {
- images: {
- original: image,
- resized: {
- name: 'resized_avatar.png',
- type: 'image/png',
- content: image.content,
- },
- },
- },
- update: (cache, { data }) => {
- if (!data) return
- const { userCurrentAvatarAdd } = data
- if (!userCurrentAvatarAdd?.avatar) return
- const newIdPresent = currentAvatars.value.find((avatar) => {
- return avatar.id === userCurrentAvatarAdd.avatar?.id
- })
- if (newIdPresent) return
- modifyDefaultAvatarCache(cache, currentDefaultAvatar.value, false)
- let existingAvatars = cache.readQuery<UserCurrentAvatarListQuery>({
- query: UserCurrentAvatarListDocument,
- })
- existingAvatars = {
- ...existingAvatars,
- userCurrentAvatarList: [
- ...(existingAvatars?.userCurrentAvatarList || []),
- userCurrentAvatarAdd.avatar,
- ],
- }
- cache.writeQuery({
- query: UserCurrentAvatarListDocument,
- data: existingAvatars,
- })
- },
- }),
- {
- errorNotificationMessage: __('The avatar could not be uploaded.'),
- },
- )
- addAvatarMutation.send().then((data) => {
- if (data?.userCurrentAvatarAdd?.avatar) {
- if (user.value) {
- user.value.image = data.userCurrentAvatarAdd.avatar.imageHash
- }
- notify({
- id: 'avatar-upload-success',
- type: NotificationTypes.Success,
- message: __('Your avatar has been uploaded.'),
- })
- }
- })
- }
- const addAvatarByUpload = () => {
- fileUploadElement.value?.click()
- }
- const addAvatarByCamera = () => {
- cameraFlyout.open({
- onAvatarCaptured: (image: ImageFileData) => {
- storeAvatar(image)
- },
- })
- }
- const loadAvatar = async (input: HTMLInputElement | null) => {
- const files = input?.files
- if (!files) return
- const [avatar] = await convertFileList(files)
- cropImageFlyout.open({
- image: avatar,
- onImageCropped: (image: ImageFileData) => storeAvatar(image),
- })
- // Reset input value to allow selecting the same file again
- input.value = ''
- }
- const selectAvatar = (avatar: Avatar) => {
- // Update the cache already before the
- const { cache } = getApolloClient()
- const oldDefaultAvatar = currentDefaultAvatar.value
- modifyDefaultAvatarCache(cache, oldDefaultAvatar, false)
- modifyDefaultAvatarCache(cache, avatar, true)
- const accountAvatarSelectMutation = new MutationHandler(
- useUserCurrentAvatarSelectMutation(() => ({
- variables: { id: avatar.id },
- })),
- {
- errorNotificationMessage: __('The avatar could not be selected.'),
- },
- )
- accountAvatarSelectMutation
- .send()
- .then(() => {
- notify({
- id: 'avatar-select-success',
- type: NotificationTypes.Success,
- message: __('Your avatar has been changed.'),
- })
- })
- .catch(() => {
- // Reset the cache again if the mutation fails.
- modifyDefaultAvatarCache(cache, oldDefaultAvatar, true)
- modifyDefaultAvatarCache(cache, avatar, false)
- })
- }
- const deleteAvatar = (avatar: Avatar) => {
- const accountAvatarDeleteMutation = new MutationHandler(
- useUserCurrentAvatarDeleteMutation(() => ({
- variables: { id: avatar.id },
- update(cache) {
- if (avatar.default) {
- modifyDefaultAvatarCache(cache, currentAvatars.value[0], true)
- }
- cache.evict({ id: cache.identify(avatar) })
- cache.gc()
- },
- })),
- {
- errorNotificationMessage: __('The avatar could not be deleted.'),
- },
- )
- accountAvatarDeleteMutation.send().then(() => {
- notify({
- id: 'avatar-delete-success',
- type: NotificationTypes.Success,
- message: __('Your avatar has been deleted.'),
- })
- })
- }
- const { waitForVariantConfirmation } = useConfirmation()
- const confirmDeleteAvatar = async (avatar: Avatar) => {
- const confirmed = await waitForVariantConfirmation('delete')
- if (confirmed) deleteAvatar(avatar)
- }
- const avatarButtonClasses = [
- 'cursor-pointer',
- '-:outline-transparent',
- 'hover:-:outline-blue-900',
- 'rounded-full',
- 'outline',
- 'outline-3',
- 'focus:outline-blue-800',
- 'hover:focus:outline-blue-800',
- ]
- const activeAvatarButtonClass = (active: boolean) => {
- return {
- 'outline-blue-800 hover:outline-blue-800': active,
- }
- }
- </script>
- <template>
- <LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
- <CommonLoader :loading="avatarListQueryLoading">
- <div class="mb-4">
- <CommonLabel class="!mt-0.5 mb-1 !block"
- >{{ $t('Your avatar') }}
- </CommonLabel>
- <div class="rounded-lg bg-blue-200 dark:bg-gray-700">
- <div class="flex flex-row flex-wrap gap-2.5 p-2.5">
- <template v-for="avatar in currentAvatars" :key="avatar.id">
- <button
- v-if="avatar.initial && user"
- :aria-label="$t('Select this avatar')"
- :class="[
- ...avatarButtonClasses,
- activeAvatarButtonClass(avatar.default),
- ]"
- @click.stop="avatar.default ? void 0 : selectAvatar(avatar)"
- >
- <CommonUserAvatar
- :class="{ 'avatar-selected': avatar.default }"
- :entity="user"
- class="!flex border-neutral-100 dark:border-gray-900"
- size="large"
- initials-only
- personal
- no-indicator
- no-muted
- />
- </button>
- <div
- v-else-if="avatar.imageHash"
- class="group/avatar relative flex"
- >
- <button
- :aria-label="$t('Select this avatar')"
- :class="[
- ...avatarButtonClasses,
- activeAvatarButtonClass(avatar.default),
- ]"
- @click.stop="avatar.default ? void 0 : selectAvatar(avatar)"
- >
- <CommonAvatar
- :class="{ 'avatar-selected': avatar.default }"
- :image="`${apiUrl}/users/image/${avatar.imageHash}`"
- class="!flex border-neutral-100 dark:border-gray-900"
- size="large"
- >
- </CommonAvatar>
- </button>
- <CommonButton
- v-if="avatar.deletable"
- :aria-label="$t('Delete this avatar')"
- :class="{ 'opacity-0 transition-opacity': !isTouchDevice }"
- class="absolute -end-2 -top-1 text-white focus:opacity-100 group-hover/avatar:opacity-100"
- icon="x-lg"
- size="small"
- variant="remove"
- @click.stop="confirmDeleteAvatar(avatar)"
- />
- </div>
- </template>
- </div>
- <CommonDivider padding />
- <div class="w-full p-1 text-center">
- <input
- ref="file-upload"
- :accept="allowedImageTypesString()"
- aria-hidden="true"
- class="hidden"
- data-test-id="fileUploadInput"
- type="file"
- @change="loadAvatar(fileUploadElement)"
- />
- <CommonButton
- class="m-1"
- size="medium"
- prefix-icon="image"
- @click="addAvatarByUpload"
- >
- {{ $t('Upload') }}
- </CommonButton>
- <CommonButton
- class="m-1"
- size="medium"
- prefix-icon="camera"
- @click="addAvatarByCamera"
- >
- {{ $t('Camera') }}
- </CommonButton>
- </div>
- </div>
- </div>
- </CommonLoader>
- </LayoutContent>
- </template>
|