your personal website on atproto - mirror blento.app

Merge branch 'main' of github.com:flo-bit/blento into feat/links-qrcode

jycouet 4cf8c7d4 bbcdbb12

+152 -45
+1 -1
src/lib/atproto/methods.ts
··· 297 297 }; 298 298 }) { 299 299 if (!did || !blob?.ref?.$link) return ''; 300 - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`; 300 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 301 301 } 302 302 303 303 export async function searchActorsTypeahead(
+3 -12
src/lib/cards/ImageCard/ImageCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { getDidContext } from '$lib/website/context'; 3 - import { getImageBlobUrl } from '$lib/atproto'; 4 3 import type { ContentComponentProps } from '../types'; 5 4 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 5 + import { getImage } from '$lib/helper'; 6 6 7 7 let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 8 8 9 9 const did = getDidContext(); 10 - 11 - function getSrc() { 12 - if (item.cardData.objectUrl) return item.cardData.objectUrl; 13 - 14 - if (item.cardData.image && typeof item.cardData.image === 'object') { 15 - return getImageBlobUrl({ did, blob: item.cardData.image }); 16 - } 17 - return item.cardData.image; 18 - } 19 10 </script> 20 11 21 - {#key item.cardData.image || item.cardData.objectUrl} 12 + {#key getImage(item.cardData, did, 'image')} 22 13 <img 23 14 class={[ 24 15 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 25 16 item.cardData.href ? 'group-hover/card:scale-101' : '' 26 17 ]} 27 - src={getSrc()} 18 + src={getImage(item.cardData, did, 'image')} 28 19 alt="" 29 20 /> 30 21 {/key}
+19 -13
src/lib/cards/ImageCard/index.ts
··· 1 - import { uploadBlob } from '$lib/atproto'; 1 + import { checkAndUploadImage } from '$lib/helper'; 2 2 import type { CardDefinition } from '../types'; 3 3 import ImageCard from './ImageCard.svelte'; 4 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; 5 8 6 9 export const ImageCardDefinition = { 7 10 type: 'image', ··· 15 18 }; 16 19 }, 17 20 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 - 21 + await checkAndUploadImage(item.cardData, 'image'); 30 22 return item; 31 23 }, 32 24 settingsComponent: ImageCardSettings, ··· 36 28 change: (item) => { 37 29 return item; 38 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 + 39 45 name: 'Image Card', 40 46 41 47 canHaveLabel: true
+10 -3
src/lib/cards/LinkCard/EditingLinkCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/website/context'; 3 + import { getImage } from '$lib/helper'; 4 + import { getDidContext, getIsMobile } from '$lib/website/context'; 4 5 import type { ContentComponentProps } from '../types'; 5 6 import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 6 7 ··· 50 51 isFetchingMetadata = false; 51 52 }); 52 53 }); 54 + 55 + let did = getDidContext(); 53 56 </script> 54 57 55 58 <div class="relative flex h-full flex-col justify-between p-4"> ··· 68 71 <img 69 72 class="size-6 rounded-lg object-cover" 70 73 onerror={() => (faviconHasError = true)} 71 - src={item.cardData.favicon} 74 + src={getImage(item.cardData, did, 'favicon')} 72 75 alt="" 73 76 /> 74 77 {:else} ··· 119 122 </div> 120 123 121 124 {#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="" /> 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 + /> 123 130 {/if} 124 131 </div>
+6 -3
src/lib/cards/LinkCard/LinkCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/website/context'; 3 + import { getImage } from '$lib/helper'; 4 + import { getDidContext, getIsMobile } from '$lib/website/context'; 4 5 import type { ContentComponentProps } from '../types'; 5 6 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 6 7 ··· 9 10 let isMobile = getIsMobile(); 10 11 11 12 let faviconHasError = $state(false); 13 + 14 + let did = getDidContext(); 12 15 </script> 13 16 14 17 <div class="flex h-full flex-col justify-between p-4"> ··· 58 61 59 62 {#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 60 63 <img 61 - class="mb-2 max-h-32 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 62 - src={item.cardData.image} 64 + class="mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 65 + src={getImage(item.cardData, did)} 63 66 alt="" 64 67 /> 65 68 {/if}
+6 -1
src/lib/cards/LinkCard/index.ts
··· 1 - import { validateLink } from '$lib/helper'; 1 + import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 2 import type { CardDefinition } from '../types'; 3 3 import EditingLinkCard from './EditingLinkCard.svelte'; 4 4 import LinkCard from './LinkCard.svelte'; ··· 29 29 item.cardData.href = url; 30 30 item.cardData.domain = new URL(url).hostname; 31 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'); 32 37 return item; 33 38 }, 34 39 urlHandlerPriority: 0
+60 -10
src/lib/helper.ts
··· 1 1 import type { Item, WebsiteData } from './types'; 2 2 import { COLUMNS, margin, mobileMargin } from '$lib'; 3 3 import { CardDefinitionsByType } from './cards'; 4 - import { deleteRecord, putRecord } from '$lib/atproto'; 4 + import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 5 import { toast } from '@foxui/core'; 6 6 import * as TID from '@atcute/tid'; 7 7 ··· 337 337 } 338 338 } 339 339 340 - export function compressImage(file: File, maxSize: number = 900 * 1024): Promise<Blob> { 340 + export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> { 341 341 return new Promise((resolve, reject) => { 342 342 const img = new Image(); 343 343 const reader = new FileReader(); ··· 353 353 reader.readAsDataURL(file); 354 354 355 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 + 356 364 let width = img.width; 357 365 let height = img.height; 358 - const maxDimension = 2048; 359 366 360 367 if (width > maxDimension || height > maxDimension) { 361 368 if (width > height) { ··· 375 382 if (!ctx) return reject(new Error('Failed to get canvas context.')); 376 383 ctx.drawImage(img, 0, 0, width, height); 377 384 378 - // Function to try compressing at a given quality 379 - let quality = 0.8; 385 + // Use WebP for both compression and transparency support 386 + let quality = 0.9; 387 + 380 388 function attemptCompression() { 381 389 canvas.toBlob( 382 390 (blob) => { 383 391 if (!blob) { 384 392 return reject(new Error('Compression failed.')); 385 393 } 386 - // If the blob is under our size limit, or quality is too low, resolve it 387 394 if (blob.size <= maxSize || quality < 0.3) { 388 - console.log('Compression successful. Blob size:', blob.size); 389 - console.log('Quality:', quality); 390 395 resolve(blob); 391 396 } else { 392 - // Otherwise, reduce the quality and try again 393 397 quality -= 0.1; 394 398 attemptCompression(); 395 399 } 396 400 }, 397 - 'image/jpeg', 401 + 'image/webp', 398 402 quality 399 403 ); 400 404 } ··· 536 540 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 537 541 } 538 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 320 321 321 item.cardType = isGif ? 'gif' : 'image'; 322 322 item.cardData = { 323 - blob: processedFile, 324 - objectUrl 323 + image: { blob: processedFile, objectUrl } 325 324 }; 326 325 327 326 // If grid position is provided ··· 492 491 // Reset the input so the same file can be selected again 493 492 target.value = ''; 494 493 } 494 + 495 + $inspect(items); 495 496 </script> 496 497 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 + }