/ src / components / CodexImage.vue
CodexImage.vue
  1  <script setup>
  2  import { inject, ref, onMounted, computed, watch, onUnmounted } from 'vue'
  3  import SpinnerLoading from '@/components/SpinnerLoading.vue'
  4  
  5  const codexApi = inject('codexApi')
  6  const loading = ref(false)
  7  const imgSrc = ref('')
  8  const error = ref('')
  9  
 10  const props = defineProps({
 11    cid: {
 12      type: [String, undefined]
 13    },
 14    localOnly: {
 15      // only try downloading from the local node
 16      type: Boolean,
 17      default: false
 18    },
 19    alt: String,
 20    moderated: {
 21      type: [String],
 22      default() {
 23        return 'pending'
 24      },
 25      validator(value, props) {
 26        // The value must match one of these strings
 27        return ['pending', 'approved', 'banned'].includes(value)
 28      }
 29    },
 30    timeout: {
 31      type: Number,
 32      default() {
 33        return 120000
 34      }
 35    },
 36    blurClass: {
 37      type: String,
 38      default() {
 39        return 'blur-xxl'
 40      },
 41      validator(value, props) {
 42        return ['blur', 'blur-xxl'].includes(value)
 43      }
 44    }
 45  })
 46  const hidden = computed(() => props.cid === undefined)
 47  const blurred = computed(() => ['pending', 'banned'].includes(props.moderated))
 48  const imageClassObj = computed(() => {
 49    let obj = {}
 50    obj[props.blurClass] = blurred.value
 51    return obj
 52  })
 53  
 54  const controller = new AbortController()
 55  
 56  async function fetchImage(cid) {
 57    if (hidden.value) {
 58      return
 59    }
 60    loading.value = true
 61  
 62    const timeoutSignal = AbortSignal.timeout(props.timeout)
 63  
 64    try {
 65      let response
 66      let options = {
 67        // This will abort the fetch when either signal is aborted
 68        signal: AbortSignal.any([controller.signal, timeoutSignal]),
 69        // The browser looks for a matching request in its HTTP cache.
 70        // If there is a match, fresh or stale, it will be returned from the cache.
 71        // If there is no match, the browser will make a normal request, and will update the cache with the downloaded resource.
 72        cache: 'force-cache'
 73      }
 74      if (props.localOnly) {
 75        response = await codexApi.downloadLocal(cid, options)
 76      } else {
 77        response = await codexApi.download(cid, options)
 78      }
 79      if (!response.ok) {
 80        throw new Error(`${response.status} ${response.statusText}`)
 81      }
 82      try {
 83        const blob = await response.blob()
 84        imgSrc.value = URL.createObjectURL(blob)
 85      } catch (e) {
 86        error.value = `not an image (error: ${e.message})`
 87      }
 88    } catch (e) {
 89      if (e.name === 'AbortError') {
 90        console.log(`image fetch aborted for cid ${props.cid}`)
 91      } else if (e.name === 'TimeoutError') {
 92        // Notify the user of timeout
 93        error.value = `image fetch for cid ${props.cid} timed out after ${props.timeout}ms`
 94      } else {
 95        error.value = `failed to download cid data: ${e.message}`
 96      }
 97    } finally {
 98      loading.value = false
 99    }
100  }
101  
102  watch(() => props.cid, fetchImage, { immediate: true })
103  
104  onUnmounted(() => {
105    controller.abort() // abort image fetch
106  })
107  </script>
108  
109  <template>
110    <div v-if="!hidden" class="text-center">
111      <SpinnerLoading v-if="loading" />
112      <div
113        v-else-if="error"
114        v-bind="$attrs"
115        class="dark:bg-orange-700 dark:text-orange-200"
116        :title="error"
117      >
118        <svg
119          class="text-red-500 fill-red-100 dark:text-white"
120          aria-hidden="true"
121          xmlns="http://www.w3.org/2000/svg"
122          fill="currentColor"
123          viewBox="0 0 20 20"
124        >
125          <path
126            d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z"
127          />
128        </svg>
129        <span class="sr-only">{{ error }}</span>
130      </div>
131  
132      <img
133        v-else-if="imgSrc"
134        :src="imgSrc"
135        class="rounded-lg"
136        :alt="props.alt"
137        :class="imageClassObj"
138      />
139    </div>
140  </template>
141  <style scoped>
142  .blur-xxl {
143    --tw-blur: blur(76px);
144    filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale)
145      var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
146  }
147  </style>