your personal website on atproto - mirror
blento.app
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>