your personal website on atproto - mirror blento.app
at mobile-editing 272 lines 8.3 kB view raw
1<script lang="ts"> 2 import { browser } from '$app/environment'; 3 import { getImage, compressImage } from '$lib/helper'; 4 import { getDidContext, getIsMobile } 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 isMobile = getIsMobile(); 54 55 let faviconHasError = $state(false); 56 let isFetchingMetadata = $state(false); 57 58 let hasFetched = $derived(item.cardData.hasFetched !== false); 59 60 async function fetchMetadata() { 61 let domain: string; 62 try { 63 domain = new URL(item.cardData.href).hostname; 64 } catch { 65 return; 66 } 67 item.cardData.domain = domain; 68 faviconHasError = false; 69 70 try { 71 const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href)); 72 if (!response.ok) { 73 throw new Error(); 74 } 75 const data = await response.json(); 76 item.cardData.description = data.description || ''; 77 item.cardData.title = data.title || ''; 78 item.cardData.image = data.images?.[0] || ''; 79 item.cardData.favicon = data.favicons?.[0] || undefined; 80 } catch { 81 return; 82 } 83 } 84 85 $effect(() => { 86 if (hasFetched !== false || isFetchingMetadata) { 87 return; 88 } 89 90 isFetchingMetadata = true; 91 92 fetchMetadata().then(() => { 93 item.cardData.hasFetched = true; 94 isFetchingMetadata = false; 95 }); 96 }); 97 98 let did = getDidContext(); 99</script> 100 101<input 102 type="file" 103 accept="image/*" 104 class="hidden" 105 bind:this={faviconInputRef} 106 onchange={handleFaviconChange} 107/> 108<input 109 type="file" 110 accept="image/*" 111 class="hidden" 112 bind:this={imageInputRef} 113 onchange={handleImageChange} 114/> 115 116<div class="relative flex h-full flex-col justify-between p-4"> 117 <div 118 class={[ 119 'accent:bg-accent-500/50 absolute inset-0 z-20 bg-white/50 dark:bg-black/50', 120 !hasFetched ? 'animate-pulse' : 'hidden' 121 ]} 122 ></div> 123 124 <div class={isFetchingMetadata ? 'pointer-events-none' : ''}> 125 <button 126 type="button" 127 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" 128 onclick={() => faviconInputRef?.click()} 129 onmouseenter={() => (isHoveringFavicon = true)} 130 onmouseleave={() => (isHoveringFavicon = false)} 131 > 132 {#if hasFetched && item.cardData.favicon && !faviconHasError} 133 <img 134 class="size-6 rounded-lg object-cover" 135 onerror={() => (faviconHasError = true)} 136 src={getImage(item.cardData, did, 'favicon')} 137 alt="" 138 /> 139 {:else} 140 <svg 141 xmlns="http://www.w3.org/2000/svg" 142 fill="none" 143 viewBox="0 0 24 24" 144 stroke-width="1.5" 145 stroke="currentColor" 146 class="size-4" 147 > 148 <path 149 stroke-linecap="round" 150 stroke-linejoin="round" 151 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" 152 /> 153 </svg> 154 {/if} 155 <!-- Hover overlay --> 156 <div 157 class={[ 158 'absolute inset-0 flex items-center justify-center rounded-xl bg-black/50 transition-opacity duration-200', 159 isHoveringFavicon ? 'opacity-100' : 'opacity-0' 160 ]} 161 > 162 <svg 163 xmlns="http://www.w3.org/2000/svg" 164 fill="none" 165 viewBox="0 0 24 24" 166 stroke-width="2" 167 stroke="currentColor" 168 class="size-4 text-white" 169 > 170 <path 171 stroke-linecap="round" 172 stroke-linejoin="round" 173 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" 174 /> 175 </svg> 176 </div> 177 </button> 178 179 <div 180 class={[ 181 '-m-1 rounded-md p-1 transition-colors duration-200', 182 hasFetched 183 ? 'hover:bg-base-200/70 dark:hover:bg-base-800/70 accent:hover:bg-accent-200/30' 184 : '' 185 ]} 186 > 187 {#if hasFetched} 188 <PlainTextEditor 189 class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold" 190 key="title" 191 bind:contentDict={item.cardData} 192 placeholder="Title here" 193 /> 194 {:else} 195 <span class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold"> 196 Loading data... 197 </span> 198 {/if} 199 </div> 200 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> 201 <div 202 class="text-accent-600 accent:text-accent-950 dark:text-accent-400 mt-2 text-xs font-semibold" 203 > 204 {item.cardData.domain} 205 </div> 206 </div> 207 208 {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4))} 209 <button 210 type="button" 211 class="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" 212 onclick={() => imageInputRef?.click()} 213 onmouseenter={() => (isHoveringImage = true)} 214 onmouseleave={() => (isHoveringImage = false)} 215 > 216 {#if item.cardData.image} 217 <img 218 class="h-full w-full object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 219 src={getImage(item.cardData, did)} 220 alt="" 221 /> 222 {:else} 223 <div class="bg-base-200 dark:bg-base-800 flex h-full w-full items-center justify-center"> 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="text-base-400 dark:text-base-600 size-8" 231 > 232 <path 233 stroke-linecap="round" 234 stroke-linejoin="round" 235 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" 236 /> 237 </svg> 238 </div> 239 {/if} 240 <!-- Hover overlay --> 241 <div 242 class={[ 243 'absolute inset-0 flex items-center justify-center rounded-xl bg-black/50 transition-opacity duration-200', 244 isHoveringImage ? 'opacity-100' : 'opacity-0' 245 ]} 246 > 247 <div class="text-center text-sm text-white"> 248 <svg 249 xmlns="http://www.w3.org/2000/svg" 250 fill="none" 251 viewBox="0 0 24 24" 252 stroke-width="1.5" 253 stroke="currentColor" 254 class="mx-auto mb-1 size-6" 255 > 256 <path 257 stroke-linecap="round" 258 stroke-linejoin="round" 259 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" 260 /> 261 <path 262 stroke-linecap="round" 263 stroke-linejoin="round" 264 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" 265 /> 266 </svg> 267 <span class="font-medium">{item.cardData.image ? 'Change' : 'Add image'}</span> 268 </div> 269 </div> 270 </button> 271 {/if} 272</div>