your personal website on atproto - mirror blento.app

Merge pull request #30 from flo-bit/image-fixes

make image handling better

authored by Florian and committed by GitHub 0466dbed f2dde826

+152 -45
+1 -1
src/lib/atproto/methods.ts
··· 297 }; 298 }) { 299 if (!did || !blob?.ref?.$link) return ''; 300 - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`; 301 } 302 303 export async function searchActorsTypeahead(
··· 297 }; 298 }) { 299 if (!did || !blob?.ref?.$link) return ''; 300 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 301 } 302 303 export async function searchActorsTypeahead(
+3 -12
src/lib/cards/ImageCard/ImageCard.svelte
··· 1 <script lang="ts"> 2 import { getDidContext } from '$lib/website/context'; 3 - import { getImageBlobUrl } from '$lib/atproto'; 4 import type { ContentComponentProps } from '../types'; 5 6 let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 7 8 const did = getDidContext(); 9 - 10 - function getSrc() { 11 - if (item.cardData.objectUrl) return item.cardData.objectUrl; 12 - 13 - if (item.cardData.image && typeof item.cardData.image === 'object') { 14 - return getImageBlobUrl({ did, blob: item.cardData.image }); 15 - } 16 - return item.cardData.image; 17 - } 18 </script> 19 20 - {#key item.cardData.image || item.cardData.objectUrl} 21 <img 22 class={[ 23 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 24 item.cardData.href ? 'group-hover/card:scale-101' : '' 25 ]} 26 - src={getSrc()} 27 alt="" 28 /> 29 {/key}
··· 1 <script lang="ts"> 2 import { getDidContext } from '$lib/website/context'; 3 import type { ContentComponentProps } from '../types'; 4 + import { getImage } from '$lib/helper'; 5 6 let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 7 8 const did = getDidContext(); 9 </script> 10 11 + {#key getImage(item.cardData, did, 'image')} 12 <img 13 class={[ 14 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 15 item.cardData.href ? 'group-hover/card:scale-101' : '' 16 ]} 17 + src={getImage(item.cardData, did, 'image')} 18 alt="" 19 /> 20 {/key}
+19 -13
src/lib/cards/ImageCard/index.ts
··· 1 - import { uploadBlob } from '$lib/atproto'; 2 import type { CardDefinition } from '../types'; 3 import ImageCard from './ImageCard.svelte'; 4 import ImageCardSettings from './ImageCardSettings.svelte'; 5 6 export const ImageCardDefinition = { 7 type: 'image', ··· 15 }; 16 }, 17 upload: async (item) => { 18 - if (item.cardData.blob) { 19 - item.cardData.image = await uploadBlob({ blob: item.cardData.blob }); 20 - 21 - delete item.cardData.blob; 22 - } 23 - 24 - if (item.cardData.objectUrl) { 25 - URL.revokeObjectURL(item.cardData.objectUrl); 26 - 27 - delete item.cardData.objectUrl; 28 - } 29 - 30 return item; 31 }, 32 settingsComponent: ImageCardSettings, ··· 36 change: (item) => { 37 return item; 38 }, 39 name: 'Image Card', 40 41 canHaveLabel: true
··· 1 + import { checkAndUploadImage } from '$lib/helper'; 2 import type { CardDefinition } from '../types'; 3 import ImageCard from './ImageCard.svelte'; 4 import ImageCardSettings from './ImageCardSettings.svelte'; 5 + 6 + // Common image extensions 7 + const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|bmp|ico|avif|tiff?)(\?.*)?$/i; 8 9 export const ImageCardDefinition = { 10 type: 'image', ··· 18 }; 19 }, 20 upload: async (item) => { 21 + await checkAndUploadImage(item.cardData, 'image'); 22 return item; 23 }, 24 settingsComponent: ImageCardSettings, ··· 28 change: (item) => { 29 return item; 30 }, 31 + 32 + onUrlHandler: (url, item) => { 33 + // Check if URL points to an image 34 + if (IMAGE_EXTENSIONS.test(url)) { 35 + item.cardType = 'image'; 36 + item.cardData.image = url; 37 + item.cardData.alt = ''; 38 + item.cardData.href = ''; 39 + return item; 40 + } 41 + return null; 42 + }, 43 + urlHandlerPriority: 3, 44 + 45 name: 'Image Card', 46 47 canHaveLabel: true
+10 -3
src/lib/cards/LinkCard/EditingLinkCard.svelte
··· 1 <script lang="ts"> 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/website/context'; 4 import type { ContentComponentProps } from '../types'; 5 import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 6 ··· 50 isFetchingMetadata = false; 51 }); 52 }); 53 </script> 54 55 <div class="relative flex h-full flex-col justify-between p-4"> ··· 68 <img 69 class="size-6 rounded-lg object-cover" 70 onerror={() => (faviconHasError = true)} 71 - src={item.cardData.favicon} 72 alt="" 73 /> 74 {:else} ··· 119 </div> 120 121 {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 122 - <img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" /> 123 {/if} 124 </div>
··· 1 <script lang="ts"> 2 import { browser } from '$app/environment'; 3 + import { getImage } from '$lib/helper'; 4 + import { getDidContext, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../types'; 6 import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 7 ··· 51 isFetchingMetadata = false; 52 }); 53 }); 54 + 55 + let did = getDidContext(); 56 </script> 57 58 <div class="relative flex h-full flex-col justify-between p-4"> ··· 71 <img 72 class="size-6 rounded-lg object-cover" 73 onerror={() => (faviconHasError = true)} 74 + src={getImage(item.cardData, did, 'favicon')} 75 alt="" 76 /> 77 {:else} ··· 122 </div> 123 124 {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 125 + <img 126 + class="mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 127 + src={getImage(item.cardData, did)} 128 + alt="" 129 + /> 130 {/if} 131 </div>
+6 -3
src/lib/cards/LinkCard/LinkCard.svelte
··· 1 <script lang="ts"> 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/website/context'; 4 import type { ContentComponentProps } from '../types'; 5 6 let { item }: ContentComponentProps = $props(); ··· 8 let isMobile = getIsMobile(); 9 10 let faviconHasError = $state(false); 11 </script> 12 13 <div class="flex h-full flex-col justify-between p-4"> ··· 57 58 {#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 59 <img 60 - class="mb-2 max-h-32 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 61 - src={item.cardData.image} 62 alt="" 63 /> 64 {/if}
··· 1 <script lang="ts"> 2 import { browser } from '$app/environment'; 3 + import { getImage } from '$lib/helper'; 4 + import { getDidContext, getIsMobile } from '$lib/website/context'; 5 import type { ContentComponentProps } from '../types'; 6 7 let { item }: ContentComponentProps = $props(); ··· 9 let isMobile = getIsMobile(); 10 11 let faviconHasError = $state(false); 12 + 13 + let did = getDidContext(); 14 </script> 15 16 <div class="flex h-full flex-col justify-between p-4"> ··· 60 61 {#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 62 <img 63 + class="mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 64 + src={getImage(item.cardData, did)} 65 alt="" 66 /> 67 {/if}
+6 -1
src/lib/cards/LinkCard/index.ts
··· 1 - import { validateLink } from '$lib/helper'; 2 import type { CardDefinition } from '../types'; 3 import EditingLinkCard from './EditingLinkCard.svelte'; 4 import LinkCard from './LinkCard.svelte'; ··· 29 item.cardData.href = url; 30 item.cardData.domain = new URL(url).hostname; 31 item.cardData.hasFetched = false; 32 return item; 33 }, 34 urlHandlerPriority: 0
··· 1 + import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 import type { CardDefinition } from '../types'; 3 import EditingLinkCard from './EditingLinkCard.svelte'; 4 import LinkCard from './LinkCard.svelte'; ··· 29 item.cardData.href = url; 30 item.cardData.domain = new URL(url).hostname; 31 item.cardData.hasFetched = false; 32 + return item; 33 + }, 34 + upload: async (item) => { 35 + await checkAndUploadImage(item.cardData, 'image'); 36 + await checkAndUploadImage(item.cardData, 'favicon'); 37 return item; 38 }, 39 urlHandlerPriority: 0
+60 -10
src/lib/helper.ts
··· 1 import type { Item, WebsiteData } from './types'; 2 import { COLUMNS, margin, mobileMargin } from '$lib'; 3 import { CardDefinitionsByType } from './cards'; 4 - import { deleteRecord, putRecord } from '$lib/atproto'; 5 import { toast } from '@foxui/core'; 6 import * as TID from '@atcute/tid'; 7 ··· 337 } 338 } 339 340 - export function compressImage(file: File, maxSize: number = 900 * 1024): Promise<Blob> { 341 return new Promise((resolve, reject) => { 342 const img = new Image(); 343 const reader = new FileReader(); ··· 353 reader.readAsDataURL(file); 354 355 img.onload = () => { 356 let width = img.width; 357 let height = img.height; 358 - const maxDimension = 2048; 359 360 if (width > maxDimension || height > maxDimension) { 361 if (width > height) { ··· 375 if (!ctx) return reject(new Error('Failed to get canvas context.')); 376 ctx.drawImage(img, 0, 0, width, height); 377 378 - // Function to try compressing at a given quality 379 - let quality = 0.8; 380 function attemptCompression() { 381 canvas.toBlob( 382 (blob) => { 383 if (!blob) { 384 return reject(new Error('Compression failed.')); 385 } 386 - // If the blob is under our size limit, or quality is too low, resolve it 387 if (blob.size <= maxSize || quality < 0.3) { 388 - console.log('Compression successful. Blob size:', blob.size); 389 - console.log('Quality:', quality); 390 resolve(blob); 391 } else { 392 - // Otherwise, reduce the quality and try again 393 quality -= 0.1; 394 attemptCompression(); 395 } 396 }, 397 - 'image/jpeg', 398 quality 399 ); 400 } ··· 536 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 537 } 538 }
··· 1 import type { Item, WebsiteData } from './types'; 2 import { COLUMNS, margin, mobileMargin } from '$lib'; 3 import { CardDefinitionsByType } from './cards'; 4 + import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 import { toast } from '@foxui/core'; 6 import * as TID from '@atcute/tid'; 7 ··· 337 } 338 } 339 340 + export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> { 341 return new Promise((resolve, reject) => { 342 const img = new Image(); 343 const reader = new FileReader(); ··· 353 reader.readAsDataURL(file); 354 355 img.onload = () => { 356 + const maxDimension = 2048; 357 + 358 + // If image is already small enough, return original 359 + if (file.size <= maxSize) { 360 + console.log('skipping compression+resizing, already small enough'); 361 + return resolve(file); 362 + } 363 + 364 let width = img.width; 365 let height = img.height; 366 367 if (width > maxDimension || height > maxDimension) { 368 if (width > height) { ··· 382 if (!ctx) return reject(new Error('Failed to get canvas context.')); 383 ctx.drawImage(img, 0, 0, width, height); 384 385 + // Use WebP for both compression and transparency support 386 + let quality = 0.9; 387 + 388 function attemptCompression() { 389 canvas.toBlob( 390 (blob) => { 391 if (!blob) { 392 return reject(new Error('Compression failed.')); 393 } 394 if (blob.size <= maxSize || quality < 0.3) { 395 resolve(blob); 396 } else { 397 quality -= 0.1; 398 attemptCompression(); 399 } 400 }, 401 + 'image/webp', 402 quality 403 ); 404 } ··· 540 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 541 } 542 } 543 + 544 + export async function checkAndUploadImage( 545 + objectWithImage: Record<string, any>, 546 + key: string = 'image' 547 + ) { 548 + if (!objectWithImage[key]) return; 549 + 550 + // Already uploaded as blob 551 + if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 552 + return; 553 + } 554 + 555 + if (typeof objectWithImage[key] === 'string') { 556 + // Download image from URL via proxy (to avoid CORS) and upload as blob 557 + try { 558 + const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`; 559 + const response = await fetch(proxyUrl); 560 + if (!response.ok) { 561 + console.error('Failed to fetch image:', objectWithImage[key]); 562 + return; 563 + } 564 + const blob = await response.blob(); 565 + const compressedBlob = await compressImage(blob); 566 + objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 567 + } catch (error) { 568 + console.error('Failed to download and upload image:', error); 569 + } 570 + return; 571 + } 572 + 573 + if (objectWithImage[key]?.blob) { 574 + const compressedBlob = await compressImage(objectWithImage[key].blob); 575 + objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 576 + } 577 + } 578 + 579 + export function getImage(objectWithImage: Record<string, any>, did: string, key: string = 'image') { 580 + if (!objectWithImage[key]) return; 581 + 582 + if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 583 + 584 + if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 585 + return getImageBlobUrl({ did, blob: objectWithImage[key] }); 586 + } 587 + return objectWithImage[key]; 588 + }
+3 -2
src/lib/website/EditableWebsite.svelte
··· 320 321 item.cardType = isGif ? 'gif' : 'image'; 322 item.cardData = { 323 - blob: processedFile, 324 - objectUrl 325 }; 326 327 // If grid position is provided ··· 492 // Reset the input so the same file can be selected again 493 target.value = ''; 494 } 495 </script> 496 497 <svelte:body
··· 320 321 item.cardType = isGif ? 'gif' : 'image'; 322 item.cardData = { 323 + image: { blob: processedFile, objectUrl } 324 }; 325 326 // If grid position is provided ··· 491 // Reset the input so the same file can be selected again 492 target.value = ''; 493 } 494 + 495 + $inspect(items); 496 </script> 497 498 <svelte:body
+44
src/routes/api/image-proxy/+server.ts
···
··· 1 + import { error } from '@sveltejs/kit'; 2 + 3 + export async function GET({ url }) { 4 + const imageUrl = url.searchParams.get('url'); 5 + if (!imageUrl) { 6 + throw error(400, 'No URL provided'); 7 + } 8 + 9 + try { 10 + new URL(imageUrl); 11 + } catch { 12 + throw error(400, 'Invalid URL'); 13 + } 14 + 15 + try { 16 + const response = await fetch(imageUrl); 17 + 18 + if (!response.ok) { 19 + throw error(response.status, 'Failed to fetch image'); 20 + } 21 + 22 + const contentType = response.headers.get('content-type'); 23 + 24 + // Only allow image content types 25 + if (!contentType?.startsWith('image/')) { 26 + throw error(400, 'URL does not point to an image'); 27 + } 28 + 29 + const blob = await response.blob(); 30 + 31 + return new Response(blob, { 32 + headers: { 33 + 'Content-Type': contentType, 34 + 'Cache-Control': 'public, max-age=86400' 35 + } 36 + }); 37 + } catch (err) { 38 + if (err && typeof err === 'object' && 'status' in err) { 39 + throw err; 40 + } 41 + console.error('Error proxying image:', err); 42 + throw error(500, 'Failed to proxy image'); 43 + } 44 + }