your personal website on atproto - mirror blento.app
at fix-cached-posts 369 lines 11 kB view raw
1<script lang="ts"> 2 import { browser } from '$app/environment'; 3 import { getImage, compressImage } from '$lib/helper'; 4 import { getDidContext } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../../types'; 6 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 7 8 let { item = $bindable() }: ContentComponentProps = $props(); 9 10 let faviconInputRef: HTMLInputElement; 11 let imageInputRef: HTMLInputElement; 12 let isHoveringFavicon = $state(false); 13 let isHoveringImage = $state(false); 14 15 async function handleFaviconChange(event: Event) { 16 const target = event.target as HTMLInputElement; 17 const file = target.files?.[0]; 18 if (!file) return; 19 20 try { 21 const compressedBlob = await compressImage(file, 128); 22 const objectUrl = URL.createObjectURL(compressedBlob); 23 24 item.cardData.favicon = { 25 blob: compressedBlob, 26 objectUrl 27 } as any; 28 29 faviconHasError = false; 30 } catch (error) { 31 console.error('Failed to process image:', error); 32 } 33 } 34 35 async function handleImageChange(event: Event) { 36 const target = event.target as HTMLInputElement; 37 const file = target.files?.[0]; 38 if (!file) return; 39 40 try { 41 const compressedBlob = await compressImage(file); 42 const objectUrl = URL.createObjectURL(compressedBlob); 43 44 item.cardData.image = { 45 blob: compressedBlob, 46 objectUrl 47 } as any; 48 } catch (error) { 49 console.error('Failed to process image:', error); 50 } 51 } 52 53 let faviconHasError = $state(false); 54 let isFetchingMetadata = $state(false); 55 56 let hasFetched = $derived(item.cardData.hasFetched !== false); 57 58 async function fetchMetadata() { 59 let domain: string; 60 try { 61 domain = new URL(item.cardData.href).hostname; 62 } catch { 63 return; 64 } 65 item.cardData.domain = domain; 66 faviconHasError = false; 67 68 try { 69 const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href)); 70 if (!response.ok) { 71 throw new Error(); 72 } 73 const data = await response.json(); 74 item.cardData.description = data.description || ''; 75 item.cardData.title = data.title || ''; 76 item.cardData.image = data.images?.[0] || ''; 77 item.cardData.favicon = data.favicons?.[0] || undefined; 78 } catch { 79 return; 80 } 81 } 82 83 $effect(() => { 84 if (hasFetched !== false || isFetchingMetadata) { 85 return; 86 } 87 88 isFetchingMetadata = true; 89 90 fetchMetadata().then(() => { 91 item.cardData.hasFetched = true; 92 isFetchingMetadata = false; 93 }); 94 }); 95 96 let did = getDidContext(); 97</script> 98 99<input 100 type="file" 101 accept="image/*" 102 class="hidden" 103 bind:this={faviconInputRef} 104 onchange={handleFaviconChange} 105/> 106<input 107 type="file" 108 accept="image/*" 109 class="hidden" 110 bind:this={imageInputRef} 111 onchange={handleImageChange} 112/> 113 114{#if item.cardData.showBackgroundImage} 115 <div class="relative flex h-full flex-col justify-end p-4"> 116 <div 117 class={[ 118 'accent:bg-accent-500/50 absolute inset-0 z-30 bg-white/50 dark:bg-black/50', 119 !hasFetched ? 'animate-pulse' : 'hidden' 120 ]} 121 ></div> 122 123 {#if item.cardData.image} 124 <img 125 class="absolute inset-0 -z-10 size-full object-cover" 126 src={getImage(item.cardData, did)} 127 alt="" 128 /> 129 {/if} 130 <div 131 class="from-base-50/90 via-base-50/40 dark:from-base-950/90 dark:via-base-950/40 absolute inset-0 -z-10 bg-linear-to-t to-transparent" 132 ></div> 133 134 <!-- Full card click to change image --> 135 <button 136 type="button" 137 class="absolute inset-0 z-10 cursor-pointer" 138 onclick={() => imageInputRef?.click()} 139 onmouseenter={() => (isHoveringImage = true)} 140 onmouseleave={() => (isHoveringImage = false)} 141 > 142 <div 143 class={[ 144 'absolute inset-0 flex items-center justify-center bg-black/50 transition-opacity duration-200', 145 isHoveringImage ? 'opacity-100' : 'opacity-0' 146 ]} 147 > 148 <div class="text-center text-sm text-white"> 149 <svg 150 xmlns="http://www.w3.org/2000/svg" 151 fill="none" 152 viewBox="0 0 24 24" 153 stroke-width="1.5" 154 stroke="currentColor" 155 class="mx-auto mb-1 size-6" 156 > 157 <path 158 stroke-linecap="round" 159 stroke-linejoin="round" 160 d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" 161 /> 162 <path 163 stroke-linecap="round" 164 stroke-linejoin="round" 165 d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" 166 /> 167 </svg> 168 <span class="font-medium">{item.cardData.image ? 'Change image' : 'Add image'}</span> 169 </div> 170 </div> 171 </button> 172 173 <!-- Domain and title at the bottom, above the image button --> 174 <div class="relative z-20"> 175 <div class="text-accent-600 dark:text-accent-400 text-xs font-semibold"> 176 {item.cardData.domain} 177 </div> 178 <div 179 class={[ 180 '-m-1 rounded-md p-1 transition-colors duration-200', 181 hasFetched ? 'hover:bg-base-200/70 dark:hover:bg-base-800/70' : '' 182 ]} 183 > 184 {#if hasFetched} 185 <PlainTextEditor 186 class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold" 187 key="title" 188 bind:contentDict={item.cardData} 189 placeholder="Title here" 190 /> 191 {:else} 192 <span class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold"> 193 Loading data... 194 </span> 195 {/if} 196 </div> 197 </div> 198 </div> 199{:else} 200 <div class="relative flex h-full flex-col justify-between p-4"> 201 <div 202 class={[ 203 'accent:bg-accent-500/50 absolute inset-0 z-20 bg-white/50 dark:bg-black/50', 204 !hasFetched ? 'animate-pulse' : 'hidden' 205 ]} 206 ></div> 207 208 <div class={isFetchingMetadata ? 'pointer-events-none' : ''}> 209 <button 210 type="button" 211 class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 hover:ring-accent-500 relative mb-2 inline-flex size-8 cursor-pointer items-center justify-center rounded-xl border transition-all duration-200 hover:ring-2" 212 onclick={() => faviconInputRef?.click()} 213 onmouseenter={() => (isHoveringFavicon = true)} 214 onmouseleave={() => (isHoveringFavicon = false)} 215 > 216 {#if hasFetched && item.cardData.favicon && !faviconHasError} 217 <img 218 class="size-6 rounded-lg object-cover" 219 onerror={() => (faviconHasError = true)} 220 src={getImage(item.cardData, did, 'favicon')} 221 alt="" 222 /> 223 {:else} 224 <svg 225 xmlns="http://www.w3.org/2000/svg" 226 fill="none" 227 viewBox="0 0 24 24" 228 stroke-width="1.5" 229 stroke="currentColor" 230 class="size-4" 231 > 232 <path 233 stroke-linecap="round" 234 stroke-linejoin="round" 235 d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 1 1.242 7.244" 236 /> 237 </svg> 238 {/if} 239 <!-- Hover overlay --> 240 <div 241 class={[ 242 'absolute inset-0 flex items-center justify-center rounded-xl bg-black/50 transition-opacity duration-200', 243 isHoveringFavicon ? 'opacity-100' : 'opacity-0' 244 ]} 245 > 246 <svg 247 xmlns="http://www.w3.org/2000/svg" 248 fill="none" 249 viewBox="0 0 24 24" 250 stroke-width="2" 251 stroke="currentColor" 252 class="size-4 text-white" 253 > 254 <path 255 stroke-linecap="round" 256 stroke-linejoin="round" 257 d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" 258 /> 259 </svg> 260 </div> 261 </button> 262 263 <div 264 class={[ 265 '-m-1 rounded-md p-1 transition-colors duration-200', 266 hasFetched 267 ? 'hover:bg-base-200/70 dark:hover:bg-base-800/70 accent:hover:bg-accent-200/30' 268 : '' 269 ]} 270 > 271 {#if hasFetched} 272 <PlainTextEditor 273 class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold" 274 key="title" 275 bind:contentDict={item.cardData} 276 placeholder="Title here" 277 /> 278 {:else} 279 <span class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold"> 280 Loading data... 281 </span> 282 {/if} 283 </div> 284 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> 285 <div 286 class="text-accent-600 accent:text-accent-950 dark:text-accent-400 mt-2 text-xs font-semibold" 287 > 288 {item.cardData.domain} 289 </div> 290 </div> 291 292 {#if hasFetched && browser} 293 <button 294 type="button" 295 class="link-preview-editor hover:ring-accent-500 relative mb-2 aspect-2/1 w-full cursor-pointer overflow-hidden rounded-xl transition-all duration-200 hover:ring-2" 296 onclick={() => imageInputRef?.click()} 297 onmouseenter={() => (isHoveringImage = true)} 298 onmouseleave={() => (isHoveringImage = false)} 299 > 300 {#if item.cardData.image} 301 <img 302 class="h-full w-full object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 303 src={getImage(item.cardData, did)} 304 alt="" 305 /> 306 {:else} 307 <div class="bg-base-200 dark:bg-base-800 flex h-full w-full items-center justify-center"> 308 <svg 309 xmlns="http://www.w3.org/2000/svg" 310 fill="none" 311 viewBox="0 0 24 24" 312 stroke-width="1.5" 313 stroke="currentColor" 314 class="text-base-400 dark:text-base-600 size-8" 315 > 316 <path 317 stroke-linecap="round" 318 stroke-linejoin="round" 319 d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 320 /> 321 </svg> 322 </div> 323 {/if} 324 <!-- Hover overlay --> 325 <div 326 class={[ 327 'absolute inset-0 flex items-center justify-center rounded-xl bg-black/50 transition-opacity duration-200', 328 isHoveringImage ? 'opacity-100' : 'opacity-0' 329 ]} 330 > 331 <div class="text-center text-sm text-white"> 332 <svg 333 xmlns="http://www.w3.org/2000/svg" 334 fill="none" 335 viewBox="0 0 24 24" 336 stroke-width="1.5" 337 stroke="currentColor" 338 class="mx-auto mb-1 size-6" 339 > 340 <path 341 stroke-linecap="round" 342 stroke-linejoin="round" 343 d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" 344 /> 345 <path 346 stroke-linecap="round" 347 stroke-linejoin="round" 348 d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" 349 /> 350 </svg> 351 <span class="font-medium">{item.cardData.image ? 'Change' : 'Add image'}</span> 352 </div> 353 </div> 354 </button> 355 {/if} 356 </div> 357{/if} 358 359<style> 360 .link-preview-editor { 361 display: none; 362 } 363 364 @container card (height >= 18rem) { 365 .link-preview-editor { 366 display: block; 367 } 368 } 369</style>