your personal website on atproto - mirror blento.app
at switch-grid-layout 388 lines 10 kB view raw
1import type { Item, WebsiteData } from './types'; 2import { COLUMNS, margin, mobileMargin } from '$lib'; 3import { CardDefinitionsByType } from './cards'; 4import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5import * as TID from '@atcute/tid'; 6export function clamp(value: number, min: number, max: number): number { 7 return Math.min(Math.max(value, min), max); 8} 9 10export const colors = [ 11 'bg-red-500', 12 'bg-orange-500', 13 'bg-amber-500', 14 'bg-yellow-500', 15 'bg-lime-500', 16 'bg-green-500', 17 'bg-emerald-500', 18 'bg-teal-500', 19 'bg-cyan-500', 20 'bg-sky-500', 21 'bg-blue-500', 22 'bg-indigo-500', 23 'bg-violet-500', 24 'bg-purple-500', 25 'bg-fuchsia-500', 26 'bg-pink-500', 27 'bg-rose-500' 28]; 29 30export function sortItems(a: Item, b: Item) { 31 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; 32} 33 34export function cardsEqual(a: Item, b: Item) { 35 return ( 36 a.id === b.id && 37 a.cardType === b.cardType && 38 JSON.stringify(a.cardData) === JSON.stringify(b.cardData) && 39 a.w === b.w && 40 a.h === b.h && 41 a.mobileW === b.mobileW && 42 a.mobileH === b.mobileH && 43 a.x === b.x && 44 a.y === b.y && 45 a.mobileX === b.mobileX && 46 a.mobileY === b.mobileY && 47 a.color === b.color && 48 a.page === b.page 49 ); 50} 51 52export async function refreshData(data: { updatedAt?: number; handle: string }) { 53 const TEN_MINUTES = 10 * 60 * 1000; 54 const now = Date.now(); 55 56 if (now - (data.updatedAt || 0) > TEN_MINUTES) { 57 try { 58 await fetch('/' + data.handle + '/api/refresh'); 59 console.log('successfully refreshed data', data.handle); 60 } catch (error) { 61 console.error('error refreshing data', error); 62 } 63 } else { 64 console.log('data still fresh, skipping refreshing', data.handle); 65 } 66} 67 68export function getName(data: WebsiteData): string { 69 return data.publication?.name || data.profile.displayName || data.handle; 70} 71 72export function getDescription(data: WebsiteData): string { 73 return data.publication?.description ?? data.profile.description ?? ''; 74} 75 76export function getHideProfileSection(data: WebsiteData): boolean { 77 if (data?.publication?.preferences?.hideProfileSection !== undefined) 78 return data?.publication?.preferences?.hideProfileSection; 79 80 if (data?.publication?.preferences?.hideProfile !== undefined) 81 return data?.publication?.preferences?.hideProfile; 82 83 return data.page !== 'blento.self'; 84} 85 86export function getProfilePosition(data: WebsiteData): 'side' | 'top' { 87 return data?.publication?.preferences?.profilePosition ?? 'side'; 88} 89 90export function isTyping() { 91 const active = document.activeElement; 92 93 const isEditable = 94 active instanceof HTMLInputElement || 95 active instanceof HTMLTextAreaElement || 96 // @ts-expect-error this fine 97 active?.isContentEditable; 98 99 return isEditable; 100} 101 102export function validateLink( 103 link: string | undefined, 104 tryAdding: boolean = true 105): string | undefined { 106 if (!link) return; 107 try { 108 new URL(link); 109 110 return link; 111 } catch (e) { 112 if (!tryAdding) return; 113 114 try { 115 link = 'https://' + link; 116 new URL(link); 117 118 return link; 119 } catch (e) { 120 return; 121 } 122 } 123} 124 125export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> { 126 return new Promise((resolve, reject) => { 127 const img = new Image(); 128 const reader = new FileReader(); 129 130 reader.onload = (e) => { 131 if (!e.target?.result) { 132 return reject(new Error('Failed to read file.')); 133 } 134 img.src = e.target.result as string; 135 }; 136 137 reader.onerror = (err) => reject(err); 138 reader.readAsDataURL(file); 139 140 img.onload = () => { 141 const maxDimension = 2048; 142 143 // If image is already small enough, return original 144 if (file.size <= maxSize) { 145 console.log('skipping compression+resizing, already small enough'); 146 return resolve(file); 147 } 148 149 let width = img.width; 150 let height = img.height; 151 152 if (width > maxDimension || height > maxDimension) { 153 if (width > height) { 154 height = Math.round((maxDimension / width) * height); 155 width = maxDimension; 156 } else { 157 width = Math.round((maxDimension / height) * width); 158 height = maxDimension; 159 } 160 } 161 162 // Create a canvas to draw the image 163 const canvas = document.createElement('canvas'); 164 canvas.width = width; 165 canvas.height = height; 166 const ctx = canvas.getContext('2d'); 167 if (!ctx) return reject(new Error('Failed to get canvas context.')); 168 ctx.drawImage(img, 0, 0, width, height); 169 170 // Use WebP for both compression and transparency support 171 let quality = 0.9; 172 173 function attemptCompression() { 174 canvas.toBlob( 175 (blob) => { 176 if (!blob) { 177 return reject(new Error('Compression failed.')); 178 } 179 if (blob.size <= maxSize || quality < 0.3) { 180 resolve(blob); 181 } else { 182 quality -= 0.1; 183 attemptCompression(); 184 } 185 }, 186 'image/webp', 187 quality 188 ); 189 } 190 191 attemptCompression(); 192 }; 193 194 img.onerror = (err) => reject(err); 195 }); 196} 197 198export async function savePage( 199 data: WebsiteData, 200 currentItems: Item[], 201 originalPublication: string 202) { 203 const promises = []; 204 205 // Build a lookup of original cards by ID for O(1) access 206 const originalCardsById = new Map<string, Item>(); 207 for (const card of data.cards) { 208 originalCardsById.set(card.id, card); 209 } 210 211 // find all cards that have been updated (where items differ from originalItems) 212 for (let item of currentItems) { 213 const orig = originalCardsById.get(item.id); 214 const originalItem = orig && cardsEqual(orig, item) ? orig : undefined; 215 216 if (!originalItem) { 217 console.log('updated or new item', item); 218 item.updatedAt = new Date().toISOString(); 219 // run optional upload function for this card type 220 const cardDef = CardDefinitionsByType[item.cardType]; 221 222 if (cardDef?.upload) { 223 item = await cardDef?.upload(item); 224 } 225 226 const parsedItem = JSON.parse(JSON.stringify(item)); 227 228 parsedItem.page = data.page; 229 parsedItem.version = 2; 230 231 promises.push( 232 putRecord({ 233 collection: 'app.blento.card', 234 rkey: parsedItem.id, 235 record: parsedItem 236 }) 237 ); 238 } 239 } 240 241 // delete items that are in originalItems but not in items 242 for (const originalItem of data.cards) { 243 const item = currentItems.find((i) => i.id === originalItem.id); 244 if (!item) { 245 console.log('deleting item', originalItem); 246 promises.push(deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id })); 247 } 248 } 249 250 if ( 251 data.publication?.preferences?.hideProfile !== undefined && 252 data.publication?.preferences?.hideProfileSection === undefined 253 ) { 254 data.publication.preferences.hideProfileSection = data.publication?.preferences?.hideProfile; 255 } 256 257 if (!originalPublication || originalPublication !== JSON.stringify(data.publication)) { 258 data.publication ??= { 259 name: getName(data), 260 description: getDescription(data), 261 preferences: { 262 hideProfileSection: getHideProfileSection(data) 263 } 264 }; 265 266 if (!data.publication.url) { 267 data.publication.url = 'https://blento.app/' + data.handle; 268 269 if (data.page !== 'blento.self') { 270 data.publication.url += '/' + data.page.replace('blento.', ''); 271 } 272 } 273 if (data.page !== 'blento.self') { 274 promises.push( 275 putRecord({ 276 collection: 'app.blento.page', 277 rkey: data.page, 278 record: data.publication 279 }) 280 ); 281 } else { 282 promises.push( 283 putRecord({ 284 collection: 'site.standard.publication', 285 rkey: data.page, 286 record: data.publication 287 }) 288 ); 289 } 290 291 console.log('updating or adding publication', data.publication); 292 } 293 294 await Promise.all(promises); 295} 296 297export function createEmptyCard(page: string) { 298 return { 299 id: TID.now(), 300 x: 0, 301 y: 0, 302 w: 2, 303 h: 2, 304 mobileH: 4, 305 mobileW: 4, 306 mobileX: 0, 307 mobileY: 0, 308 cardType: '', 309 cardData: {}, 310 page 311 } as Item; 312} 313 314export function scrollToItem( 315 item: Item, 316 isMobile: boolean, 317 container: HTMLDivElement | undefined, 318 force: boolean = false 319) { 320 // scroll to newly created card only if not fully visible 321 const containerRect = container?.getBoundingClientRect(); 322 if (!containerRect) return; 323 const currentMargin = isMobile ? mobileMargin : margin; 324 const currentY = isMobile ? item.mobileY : item.y; 325 const currentH = isMobile ? item.mobileH : item.h; 326 const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS; 327 328 const cardTop = containerRect.top + currentMargin + currentY * cellSize; 329 const cardBottom = containerRect.top + currentMargin + (currentY + currentH) * cellSize; 330 331 const isFullyVisible = cardTop >= 0 && cardBottom <= window.innerHeight; 332 333 if (!isFullyVisible || force) { 334 const bodyRect = document.body.getBoundingClientRect(); 335 const offset = containerRect.top - bodyRect.top; 336 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 337 } 338} 339 340export async function checkAndUploadImage( 341 objectWithImage: Record<string, any>, 342 key: string = 'image' 343) { 344 if (!objectWithImage[key]) return; 345 346 // Already uploaded as blob 347 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 348 return; 349 } 350 351 if (typeof objectWithImage[key] === 'string') { 352 // Download image from URL via proxy (to avoid CORS) and upload as blob 353 try { 354 const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`; 355 const response = await fetch(proxyUrl); 356 if (!response.ok) { 357 console.error('Failed to fetch image:', objectWithImage[key]); 358 return; 359 } 360 const blob = await response.blob(); 361 const compressedBlob = await compressImage(blob); 362 objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 363 } catch (error) { 364 console.error('Failed to download and upload image:', error); 365 } 366 return; 367 } 368 369 if (objectWithImage[key]?.blob) { 370 const compressedBlob = await compressImage(objectWithImage[key].blob); 371 objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 372 } 373} 374 375export function getImage( 376 objectWithImage: Record<string, any> | undefined, 377 did: string, 378 key: string = 'image' 379) { 380 if (!objectWithImage?.[key]) return; 381 382 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 383 384 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 385 return getCDNImageBlobUrl({ did, blob: objectWithImage[key] }); 386 } 387 return objectWithImage[key]; 388}