CommonFilePreview.vue 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { computed, ref } from 'vue'
  4. import { useAppName } from '#shared/composables/useAppName.ts'
  5. import { useSharedVisualConfig } from '#shared/composables/useSharedVisualConfig.ts'
  6. import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
  7. import type { StoredFile } from '#shared/graphql/types.ts'
  8. import { i18n } from '#shared/i18n.ts'
  9. import { getFilePreviewClasses } from '#shared/initializer/initializeFilePreviewClasses.ts'
  10. import {
  11. canDownloadFile,
  12. canPreviewFile,
  13. humanizeFileSize,
  14. type FilePreview,
  15. } from '#shared/utils/files.ts'
  16. import { getIconByContentType } from '#shared/utils/icons.ts'
  17. export interface Props {
  18. file: Pick<StoredFile, 'type' | 'name' | 'size'>
  19. downloadUrl?: string
  20. previewUrl?: string
  21. loading?: boolean
  22. noPreview?: boolean
  23. noRemove?: boolean
  24. wrapperClass?: string
  25. iconClass?: string
  26. sizeClass?: string
  27. }
  28. const props = defineProps<Props>()
  29. const emit = defineEmits<{
  30. remove: []
  31. preview: [$event: Event, type: FilePreview]
  32. }>()
  33. const appName = useAppName()
  34. const imageFailed = ref(false)
  35. const canPreview = computed(() => {
  36. const { file, previewUrl } = props
  37. if (!previewUrl || imageFailed.value) return false
  38. const type = canPreviewFile(file.type)
  39. // Currently mobile allows only preview of images.
  40. if (appName === 'mobile' && type !== 'image') return false
  41. return type
  42. })
  43. const canDownload = computed(() => canDownloadFile(props.file.type))
  44. const icon = computed(() => getIconByContentType(props.file.type))
  45. const componentType = computed(() => {
  46. if (props.downloadUrl) return 'CommonLink'
  47. return 'div'
  48. })
  49. const ariaLabel = computed(() => {
  50. if (props.downloadUrl && canDownload.value)
  51. return i18n.t('Download %s', props.file.name) // directly downloads file
  52. if (props.downloadUrl && !canDownload.value)
  53. return i18n.t('Open %s', props.file.name) // opens file in another tab
  54. return props.file.name // cannot download and preview, probably just uploaded pdf
  55. })
  56. const onPreviewClick = (event: Event) => {
  57. if (!canPreview.value) return
  58. emit('preview', event, canPreview.value)
  59. }
  60. const { isTouchDevice } = useTouchDevice()
  61. const { filePreview: filePreviewConfig } = useSharedVisualConfig()
  62. const classMap = getFilePreviewClasses()
  63. </script>
  64. <template>
  65. <div
  66. class="group/file-preview flex w-full items-center gap-2 outline-none"
  67. :class="[classMap.wrapper, wrapperClass]"
  68. >
  69. <button
  70. v-if="!noPreview && canPreview"
  71. v-tooltip="$t('Preview %s', props.file.name)"
  72. class="flex h-9 w-9 shrink-0 items-center justify-center rounded"
  73. :class="[{ border: canPreview !== 'image' }, classMap.preview]"
  74. @click="onPreviewClick"
  75. @keydown.delete.prevent="$emit('remove')"
  76. @keydown.backspace.prevent="$emit('remove')"
  77. >
  78. <template v-if="canPreview">
  79. <img
  80. v-if="canPreview === 'image'"
  81. class="h-9 w-9 rounded border object-cover"
  82. :src="previewUrl"
  83. :alt="$t('Image of %s', file.name)"
  84. @error="imageFailed = true"
  85. />
  86. <CommonIcon v-else size="base" decorative :name="icon" />
  87. </template>
  88. </button>
  89. <Component
  90. :is="componentType"
  91. v-tooltip="ariaLabel"
  92. class="flex w-full select-none items-center gap-2 overflow-hidden text-left outline-none"
  93. :class="{
  94. 'cursor-pointer': componentType !== 'div',
  95. [classMap.link]: true,
  96. }"
  97. tabindex="0"
  98. :link="downloadUrl"
  99. :download="canDownload ? file.name : undefined"
  100. :target="!canDownload ? '_blank' : undefined"
  101. @keydown.delete.prevent="$emit('remove')"
  102. @keydown.backspace.prevent="$emit('remove')"
  103. >
  104. <div
  105. v-if="!canPreview"
  106. class="flex h-9 w-9 items-center justify-center rounded border"
  107. :class="[classMap.icon, iconClass]"
  108. >
  109. <CommonIcon
  110. v-if="loading"
  111. size="base"
  112. :label="$t('File \'%s\' is uploading', file.name)"
  113. name="loading"
  114. animation="spin"
  115. />
  116. <CommonIcon v-else size="base" decorative :name="icon" />
  117. </div>
  118. <div class="flex flex-1 flex-col overflow-hidden" :class="classMap.base">
  119. <span class="line-clamp-1">
  120. {{ file.name }}
  121. </span>
  122. <span
  123. v-if="file.size"
  124. class="line-clamp-1"
  125. :class="[classMap.size, sizeClass]"
  126. >
  127. {{ humanizeFileSize(file.size) }}
  128. </span>
  129. </div>
  130. </Component>
  131. <component
  132. :is="filePreviewConfig?.buttonComponent"
  133. v-if="!noRemove"
  134. :class="{
  135. 'opacity-0 transition-opacity': !isTouchDevice,
  136. }"
  137. class="focus:opacity-100 group-hover/file-preview:opacity-100"
  138. type="button"
  139. icon="remove-attachment"
  140. :aria-label="i18n.t('Remove %s', file.name)"
  141. v-bind="filePreviewConfig?.buttonProps"
  142. @click.stop.prevent="$emit('remove')"
  143. @keypress.space.prevent="$emit('remove')"
  144. />
  145. </div>
  146. </template>