Fork of Chiri for Astro for my blog
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 350 lines 12 kB view raw
1<script> 2 import type { LinkCardMetadata } from '@/types' 3 4 let linkCardsObserver: IntersectionObserver | null = null 5 const metadataCache = new Map<string, LinkCardMetadata>() 6 7 // Fetch metadata from URL using our own proxy API with caching 8 async function fetchLinkMetadata(url: string): Promise<LinkCardMetadata | null> { 9 // Check cache first 10 if (metadataCache.has(url)) { 11 return metadataCache.get(url)! 12 } 13 14 try { 15 // Use our own proxy API 16 const proxyUrl = `/api/proxy?url=${encodeURIComponent(url)}` 17 const controller = new AbortController() 18 const timeoutId = setTimeout(() => controller.abort(), 3000) // 3 second timeout 19 20 const response = await fetch(proxyUrl, { 21 signal: controller.signal 22 }) 23 24 clearTimeout(timeoutId) 25 26 if (!response.ok) { 27 throw new Error('Failed to fetch metadata') 28 } 29 30 const html = await response.text() 31 32 // Parse HTML to extract metadata 33 const parser = new DOMParser() 34 const doc = parser.parseFromString(html, 'text/html') 35 36 const title = 37 doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || 38 doc.querySelector('meta[name="twitter:title"]')?.getAttribute('content') || 39 doc.querySelector('title')?.textContent || 40 '' 41 42 const description = 43 doc.querySelector('meta[property="og:description"]')?.getAttribute('content') || 44 doc.querySelector('meta[name="twitter:description"]')?.getAttribute('content') || 45 doc.querySelector('meta[name="description"]')?.getAttribute('content') || 46 '' 47 48 const image = 49 doc.querySelector('meta[property="og:image"]')?.getAttribute('content') || 50 doc.querySelector('meta[name="twitter:image"]')?.getAttribute('content') || 51 doc.querySelector('meta[name="image"]')?.getAttribute('content') || 52 '' 53 54 const imageAlt = doc.querySelector('meta[property="og:image:alt"]')?.getAttribute('content') || title || '' 55 56 const metadata = { 57 title: title?.trim() || '', 58 description: description?.trim() || '', 59 image: image?.trim() || '', 60 imageAlt: imageAlt?.trim() || '' 61 } 62 63 // Cache the result 64 metadataCache.set(url, metadata) 65 return metadata 66 } catch { 67 return null 68 } 69 } 70 71 // Update link card with fetched metadata 72 async function updateLinkCard(el: HTMLElement) { 73 const url = el.dataset.url 74 if (!url) { 75 return 76 } 77 78 try { 79 // Fetch and update metadata 80 const metadata = await fetchLinkMetadata(url) 81 if (!metadata) { 82 return 83 } 84 85 // Update title 86 const titleElement = el.querySelector('.link-card-title') as HTMLElement 87 if (titleElement) { 88 if (metadata.title && metadata.title.trim()) { 89 titleElement.textContent = metadata.title 90 titleElement.style.display = 'block' 91 } else { 92 // Hide title element if empty 93 titleElement.style.display = 'none' 94 } 95 } 96 97 // Update description 98 const descElement = el.querySelector('.link-card-description') as HTMLElement 99 if (descElement) { 100 if (metadata.description && metadata.description.trim()) { 101 descElement.textContent = metadata.description 102 descElement.style.display = 'block' 103 } else { 104 // Hide description element if empty 105 descElement.style.display = 'none' 106 descElement.textContent = '' 107 } 108 } 109 110 // Update image 111 const imageContainer = el.querySelector('.link-card-image') as HTMLElement 112 const imageElement = el.querySelector('.link-card-image img') as HTMLImageElement 113 if (imageContainer && imageElement && metadata.image) { 114 imageElement.src = metadata.image 115 imageElement.alt = metadata.imageAlt 116 imageContainer.style.display = 'block' 117 } 118 } catch { 119 // Handle error silently 120 } 121 } 122 123 // Set up intersection observer for link cards 124 function setupLinkCards() { 125 linkCardsObserver?.disconnect() 126 127 const linkCards = document.getElementsByClassName('link-card') 128 if (linkCards.length === 0) { 129 return 130 } 131 132 // Create an intersection observer to process link cards when they enter viewport 133 linkCardsObserver = new IntersectionObserver( 134 (entries) => { 135 // Process all intersecting cards in parallel 136 const intersectingCards = entries 137 .filter((entry) => entry.isIntersecting) 138 .map((entry) => entry.target as HTMLElement) 139 140 if (intersectingCards.length > 0) { 141 // Update domain names immediately for better perceived performance 142 intersectingCards.forEach((card) => { 143 const url = card.dataset.url 144 if (url) { 145 try { 146 const domain = new URL(url).hostname.replace('www.', '') 147 const urlElement = card.querySelector('.link-card-url') 148 if (urlElement) { 149 // Keep the SVG icon and update only the text content 150 const textSpan = urlElement.querySelector('span') 151 if (textSpan) { 152 textSpan.textContent = domain 153 } else { 154 // Fallback if span doesn't exist 155 urlElement.innerHTML = ` 156 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"> 157 <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path> 158 </svg> 159 <span>${domain}</span> 160 ` 161 } 162 } 163 } catch { 164 const urlElement = card.querySelector('.link-card-url') 165 if (urlElement) { 166 // Keep the SVG icon and update only the text content 167 const textSpan = urlElement.querySelector('span') 168 if (textSpan) { 169 textSpan.textContent = 'invalid-url' 170 } else { 171 // Fallback if span doesn't exist 172 urlElement.innerHTML = ` 173 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"> 174 <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path> 175 </svg> 176 <span>invalid-url</span> 177 ` 178 } 179 } 180 } 181 } 182 }) 183 184 // Fetch metadata in parallel 185 Promise.allSettled(intersectingCards.map((card) => updateLinkCard(card))).then(() => { 186 // Unobserve all processed cards 187 intersectingCards.forEach((card) => linkCardsObserver?.unobserve(card)) 188 }) 189 } 190 }, 191 { rootMargin: '200px' } 192 ) 193 194 Array.from(linkCards).forEach((card) => linkCardsObserver?.observe(card)) 195 } 196 197 setupLinkCards() 198 document.addEventListener('astro:page-load', setupLinkCards) 199</script> 200 201<style is:inline> 202 .prose .link-card { 203 display: flex; 204 flex-direction: column; 205 border: 0.5px solid var(--border); 206 border-radius: 8px; 207 overflow: hidden; 208 text-decoration: none; 209 color: inherit; 210 background: var(--astro-code-background); 211 min-height: 22.75px; 212 margin: 1.25rem 0 1.75rem 0; 213 transition: background 0.2s ease-out; 214 } 215 216 .prose .link-card:hover { 217 background: color-mix(in srgb, var(--selection) 75%, transparent); 218 text-decoration: none; 219 } 220 221 .prose .link-card-content { 222 padding: 0.75rem 1.25rem 0.75rem 1.25rem; 223 order: 2; 224 display: flex; 225 flex-direction: column; 226 } 227 228 .prose .link-card-image-outer { 229 padding: 0.5rem 0.5rem 0 0.5rem; 230 order: 1; 231 } 232 233 /* Hide image outer container when no image is displayed */ 234 .prose .link-card-image-outer:has(.link-card-image[style*='display: none']), 235 .prose .link-card-image-outer:not(:has(.link-card-image[style*='display: block'])) { 236 display: none; 237 } 238 239 .prose .link-card-image { 240 width: 100%; 241 aspect-ratio: 16 / 9; 242 /* Fallback for browsers that don't support aspect-ratio */ 243 height: 0; 244 padding-bottom: 56.25%; /* 16:9 aspect ratio (9/16 = 0.5625) */ 245 overflow: hidden; 246 background: var(--astro-code-background); 247 position: relative; 248 margin: 0; 249 padding: 0; 250 border-radius: 6px; 251 } 252 253 /* Modern browsers with aspect-ratio support */ 254 @supports (aspect-ratio: 16 / 9) { 255 .prose .link-card-image { 256 height: auto; 257 padding-bottom: 0; 258 } 259 } 260 261 .prose .link-card-image img { 262 position: absolute; 263 top: 0; 264 left: 0; 265 right: 0; 266 bottom: 0; 267 width: 100%; 268 height: 100%; 269 object-fit: cover; 270 object-position: center; 271 margin: 0; 272 padding: 0; 273 } 274 275 .prose .link-card-title { 276 font-size: var(--font-size-m); 277 font-weight: var(--font-weight-bold); 278 color: var(--text-primary); 279 margin: 0.25rem 0 0 0; 280 white-space: nowrap; 281 overflow: hidden; 282 text-overflow: ellipsis; 283 padding-right: 1rem; 284 order: 1; 285 } 286 287 .prose .link-card .link-card-description { 288 font-size: var(--font-size-m); 289 color: var(--text-primary); 290 opacity: 0.6; 291 margin: 0; 292 display: -webkit-box !important; 293 -webkit-line-clamp: 2 !important; 294 -webkit-box-orient: vertical !important; 295 overflow: hidden !important; 296 word-wrap: break-word !important; 297 /* Fallback for older browsers */ 298 max-height: calc(1.4em * 2) !important; 299 order: 2; 300 } 301 302 /* Hide description when display is set to none */ 303 .prose .link-card .link-card-description[style*='display: none'] { 304 display: none !important; 305 margin: 0 !important; 306 padding: 0 !important; 307 height: 0 !important; 308 overflow: hidden !important; 309 } 310 311 /* Modern browsers with line-clamp support */ 312 @supports (-webkit-line-clamp: 2) { 313 .prose .link-card .link-card-description { 314 max-height: none !important; 315 } 316 } 317 318 .prose .link-card-url { 319 color: var(--text-tertiary); 320 letter-spacing: 0.015em; 321 margin: 0.25rem 0; 322 display: flex; 323 align-items: center; 324 gap: 0.325rem; 325 order: 3; 326 } 327 328 .prose .link-card-url svg { 329 width: 0.875em; 330 height: 0.875em; 331 flex-shrink: 0; 332 vertical-align: middle; 333 } 334 335 .prose .link-card-url span { 336 font-size: var(--font-size-s); 337 vertical-align: baseline; 338 } 339 340 .prose .link-card:not(:has(.link-card-image[style*='display: block'])) { 341 padding: 0.5rem 1.25rem 0.5rem 1.25rem; 342 } 343 .prose .link-card:has(.link-card-image[style*='display: block']) .link-card-content { 344 padding: 0.5rem 1.25rem 0.5rem 1.25rem; 345 } 346 347 .prose .link-card:not(:has(.link-card-image[style*='display: block'])) .link-card-content { 348 padding: 0; 349 } 350</style>