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, 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>