Fork of Chiri for Astro for my blog
0
fork

Configure Feed

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

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