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>