your personal website on atproto - mirror blento.app
at profile-stuff-2 606 lines 17 kB view raw
1import type { Item, WebsiteData } from './types'; 2import { COLUMNS, margin, mobileMargin } from '$lib'; 3import { CardDefinitionsByType } from './cards'; 4import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5import { toast } from '@foxui/core'; 6import * as TID from '@atcute/tid'; 7 8export function clamp(value: number, min: number, max: number): number { 9 return Math.min(Math.max(value, min), max); 10} 11 12export const colors = [ 13 'bg-red-500', 14 'bg-orange-500', 15 'bg-amber-500', 16 'bg-yellow-500', 17 'bg-lime-500', 18 'bg-green-500', 19 'bg-emerald-500', 20 'bg-teal-500', 21 'bg-cyan-500', 22 'bg-sky-500', 23 'bg-blue-500', 24 'bg-indigo-500', 25 'bg-violet-500', 26 'bg-purple-500', 27 'bg-fuchsia-500', 28 'bg-pink-500', 29 'bg-rose-500' 30]; 31 32export const overlaps = (a: Item, b: Item, mobile: boolean = false) => { 33 if (a === b) return false; 34 if (mobile) { 35 return ( 36 a.mobileX < b.mobileX + b.mobileW && 37 a.mobileX + a.mobileW > b.mobileX && 38 a.mobileY < b.mobileY + b.mobileH && 39 a.mobileY + a.mobileH > b.mobileY 40 ); 41 } 42 return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; 43}; 44 45export function fixCollisions( 46 items: Item[], 47 movedItem: Item, 48 mobile: boolean = false, 49 skipCompact: boolean = false 50) { 51 const clampX = (item: Item) => { 52 if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 53 else item.x = clamp(item.x, 0, COLUMNS - item.w); 54 }; 55 56 // Push `target` down until it no longer overlaps with any item (including movedItem), 57 // while keeping target.x fixed. Any item we collide with gets pushed down first (cascade). 58 const pushDownCascade = (target: Item, blocker: Item) => { 59 // Keep x fixed always when pushing down 60 const fixedX = mobile ? target.mobileX : target.x; 61 62 // We need target to move just below `blocker` 63 const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h; 64 if (!mobile && target.y < desiredY) target.y = desiredY; 65 if (mobile && target.mobileY < desiredY) target.mobileY = desiredY; 66 67 // Now resolve any collisions that creates by pushing those items down first 68 // Repeat until target is clean. 69 while (true) { 70 const hit = items.find((it) => it !== target && overlaps(target, it, mobile)); 71 if (!hit) break; 72 73 // push the hit item down first (cascade), keeping its x fixed 74 pushDownCascade(hit, target); 75 76 // after moving the hit item, target.x must remain fixed 77 if (mobile) target.mobileX = fixedX; 78 else target.x = fixedX; 79 } 80 }; 81 82 // Ensure moved item is in bounds 83 clampX(movedItem); 84 85 // Find all items colliding with movedItem, and push them down in a stable order: 86 // top-to-bottom so you get the nice chain reaction (0,0 -> 0,1 -> 0,2). 87 const colliders = items 88 .filter((it) => it !== movedItem && overlaps(movedItem, it, mobile)) 89 .toSorted((a, b) => 90 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 91 ); 92 93 for (const it of colliders) { 94 // keep x clamped, but do NOT change x during push (we rely on fixed x) 95 clampX(it); 96 97 // push it down just below movedItem; cascade handles the rest 98 pushDownCascade(it, movedItem); 99 100 // enforce "x stays the same" during pushing (clamp already applied) 101 if (mobile) it.mobileX = clamp(it.mobileX, 0, COLUMNS - it.mobileW); 102 else it.x = clamp(it.x, 0, COLUMNS - it.w); 103 } 104 105 if (!skipCompact) { 106 compactItems(items, mobile); 107 } 108} 109 110// Fix all collisions between items (not just one moved item) 111// Items higher on the page have priority and stay in place 112export function fixAllCollisions(items: Item[], mobile: boolean = false) { 113 // Sort by Y position (top-to-bottom, then left-to-right) 114 // Items at the top have priority and won't be moved 115 const sortedItems = items.toSorted((a, b) => 116 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 117 ); 118 119 // Process each item and push it down if it overlaps with any item above it 120 for (let i = 0; i < sortedItems.length; i++) { 121 const item = sortedItems[i]; 122 123 // Clamp X to valid range 124 if (mobile) { 125 item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 126 } else { 127 item.x = clamp(item.x, 0, COLUMNS - item.w); 128 } 129 130 // Check for collisions with all items that come before (higher priority) 131 let hasCollision = true; 132 while (hasCollision) { 133 hasCollision = false; 134 for (let j = 0; j < i; j++) { 135 const other = sortedItems[j]; 136 if (overlaps(item, other, mobile)) { 137 // Push item down below the colliding item 138 if (mobile) { 139 item.mobileY = other.mobileY + other.mobileH; 140 } else { 141 item.y = other.y + other.h; 142 } 143 hasCollision = true; 144 break; // Restart collision check from the beginning 145 } 146 } 147 } 148 } 149 150 compactItems(items, mobile); 151} 152 153// Move all items up as far as possible without collisions 154export function compactItems(items: Item[], mobile: boolean = false) { 155 // Sort by Y position (top-to-bottom) so upper items settle first. 156 const sortedItems = items.toSorted((a, b) => 157 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 158 ); 159 160 for (const item of sortedItems) { 161 // Try moving item up row by row until we hit y=0 or a collision 162 while (true) { 163 const currentY = mobile ? item.mobileY : item.y; 164 if (currentY <= 0) break; 165 166 // Temporarily move up by 1 167 if (mobile) item.mobileY -= 1; 168 else item.y -= 1; 169 170 // Check for collision with any other item 171 const hasCollision = items.some((other) => other !== item && overlaps(item, other, mobile)); 172 173 if (hasCollision) { 174 // Revert the move 175 if (mobile) item.mobileY += 1; 176 else item.y += 1; 177 break; 178 } 179 // No collision, keep the new position and try moving up again 180 } 181 } 182} 183 184// Simulate where an item would end up after fixCollisions + compaction 185export function simulateFinalPosition( 186 items: Item[], 187 movedItem: Item, 188 newX: number, 189 newY: number, 190 mobile: boolean = false 191): { x: number; y: number } { 192 // Deep clone positions for simulation 193 const clonedItems: Item[] = items.map((item) => ({ 194 ...item, 195 x: item.x, 196 y: item.y, 197 mobileX: item.mobileX, 198 mobileY: item.mobileY 199 })); 200 201 const clonedMovedItem = clonedItems.find((item) => item.id === movedItem.id); 202 if (!clonedMovedItem) return { x: newX, y: newY }; 203 204 // Set the new position 205 if (mobile) { 206 clonedMovedItem.mobileX = newX; 207 clonedMovedItem.mobileY = newY; 208 } else { 209 clonedMovedItem.x = newX; 210 clonedMovedItem.y = newY; 211 } 212 213 // Run fixCollisions on the cloned data 214 fixCollisions(clonedItems, clonedMovedItem, mobile); 215 216 // Return the final position of the moved item 217 return mobile 218 ? { x: clonedMovedItem.mobileX, y: clonedMovedItem.mobileY } 219 : { x: clonedMovedItem.x, y: clonedMovedItem.y }; 220} 221 222export function sortItems(a: Item, b: Item) { 223 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; 224} 225 226export function cardsEqual(a: Item, b: Item) { 227 return ( 228 a.id === b.id && 229 a.cardType === b.cardType && 230 JSON.stringify(a.cardData) === JSON.stringify(b.cardData) && 231 a.w === b.w && 232 a.h === b.h && 233 a.mobileW === b.mobileW && 234 a.mobileH === b.mobileH && 235 a.x === b.x && 236 a.y === b.y && 237 a.mobileX === b.mobileX && 238 a.mobileY === b.mobileY && 239 a.color === b.color && 240 a.page === b.page 241 ); 242} 243 244export function setPositionOfNewItem(newItem: Item, items: Item[]) { 245 let foundPosition = false; 246 while (!foundPosition) { 247 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 248 const collision = items.find((item) => overlaps(newItem, item)); 249 if (!collision) { 250 foundPosition = true; 251 break; 252 } 253 } 254 if (!foundPosition) newItem.y += 1; 255 } 256 257 let foundMobilePosition = false; 258 while (!foundMobilePosition) { 259 for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { 260 const collision = items.find((item) => overlaps(newItem, item, true)); 261 262 if (!collision) { 263 foundMobilePosition = true; 264 break; 265 } 266 } 267 if (!foundMobilePosition) newItem.mobileY! += 1; 268 } 269} 270 271export async function refreshData(data: { updatedAt?: number; handle: string }) { 272 const TEN_MINUTES = 10 * 60 * 1000; 273 const now = Date.now(); 274 275 if (now - (data.updatedAt || 0) > TEN_MINUTES) { 276 try { 277 await fetch('/' + data.handle + '/api/refresh'); 278 console.log('successfully refreshed data', data.handle); 279 } catch (error) { 280 console.error('error refreshing data', error); 281 } 282 } else { 283 console.log('data still fresh, skipping refreshing', data.handle); 284 } 285} 286 287export function getName(data: WebsiteData): string { 288 return data.publication?.name || data.profile.displayName || data.handle; 289} 290 291export function getDescription(data: WebsiteData): string { 292 return data.publication?.description ?? data.profile.description ?? ''; 293} 294 295export function getHideProfileSection(data: WebsiteData): boolean { 296 if (data?.publication?.preferences?.hideProfileSection !== undefined) 297 return data?.publication?.preferences?.hideProfileSection; 298 299 if (data?.publication?.preferences?.hideProfile !== undefined) 300 return data?.publication?.preferences?.hideProfile; 301 302 return data.page !== 'blento.self'; 303} 304 305export function getProfilePosition(data: WebsiteData): 'side' | 'top' { 306 return data?.publication?.preferences?.profilePosition ?? 'side'; 307} 308 309export function isTyping() { 310 const active = document.activeElement; 311 312 const isEditable = 313 active instanceof HTMLInputElement || 314 active instanceof HTMLTextAreaElement || 315 // @ts-expect-error this fine 316 active?.isContentEditable; 317 318 return isEditable; 319} 320 321export function validateLink( 322 link: string | undefined, 323 tryAdding: boolean = true 324): string | undefined { 325 if (!link) return; 326 try { 327 new URL(link); 328 329 return link; 330 } catch (e) { 331 if (!tryAdding) return; 332 333 try { 334 link = 'https://' + link; 335 new URL(link); 336 337 return link; 338 } catch (e) { 339 return; 340 } 341 } 342} 343 344export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> { 345 return new Promise((resolve, reject) => { 346 const img = new Image(); 347 const reader = new FileReader(); 348 349 reader.onload = (e) => { 350 if (!e.target?.result) { 351 return reject(new Error('Failed to read file.')); 352 } 353 img.src = e.target.result as string; 354 }; 355 356 reader.onerror = (err) => reject(err); 357 reader.readAsDataURL(file); 358 359 img.onload = () => { 360 const maxDimension = 2048; 361 362 // If image is already small enough, return original 363 if (file.size <= maxSize) { 364 console.log('skipping compression+resizing, already small enough'); 365 return resolve(file); 366 } 367 368 let width = img.width; 369 let height = img.height; 370 371 if (width > maxDimension || height > maxDimension) { 372 if (width > height) { 373 height = Math.round((maxDimension / width) * height); 374 width = maxDimension; 375 } else { 376 width = Math.round((maxDimension / height) * width); 377 height = maxDimension; 378 } 379 } 380 381 // Create a canvas to draw the image 382 const canvas = document.createElement('canvas'); 383 canvas.width = width; 384 canvas.height = height; 385 const ctx = canvas.getContext('2d'); 386 if (!ctx) return reject(new Error('Failed to get canvas context.')); 387 ctx.drawImage(img, 0, 0, width, height); 388 389 // Use WebP for both compression and transparency support 390 let quality = 0.9; 391 392 function attemptCompression() { 393 canvas.toBlob( 394 (blob) => { 395 if (!blob) { 396 return reject(new Error('Compression failed.')); 397 } 398 if (blob.size <= maxSize || quality < 0.3) { 399 resolve(blob); 400 } else { 401 quality -= 0.1; 402 attemptCompression(); 403 } 404 }, 405 'image/webp', 406 quality 407 ); 408 } 409 410 attemptCompression(); 411 }; 412 413 img.onerror = (err) => reject(err); 414 }); 415} 416 417export async function savePage( 418 data: WebsiteData, 419 currentItems: Item[], 420 originalPublication: string 421) { 422 const promises = []; 423 // find all cards that have been updated (where items differ from originalItems) 424 for (let item of currentItems) { 425 const originalItem = data.cards.find((i) => cardsEqual(i, item)); 426 427 if (!originalItem) { 428 console.log('updated or new item', item); 429 item.updatedAt = new Date().toISOString(); 430 // run optional upload function for this card type 431 const cardDef = CardDefinitionsByType[item.cardType]; 432 433 if (cardDef?.upload) { 434 item = await cardDef?.upload(item); 435 } 436 437 item.page = data.page; 438 item.version = 2; 439 440 promises.push( 441 putRecord({ 442 collection: 'app.blento.card', 443 rkey: item.id, 444 record: item 445 }) 446 ); 447 } 448 } 449 450 // delete items that are in originalItems but not in items 451 for (const originalItem of data.cards) { 452 const item = currentItems.find((i) => i.id === originalItem.id); 453 if (!item) { 454 console.log('deleting item', originalItem); 455 promises.push(deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id })); 456 } 457 } 458 459 if ( 460 data.publication?.preferences?.hideProfile !== undefined && 461 data.publication?.preferences?.hideProfileSection === undefined 462 ) { 463 data.publication.preferences.hideProfileSection = data.publication?.preferences?.hideProfile; 464 } 465 466 if (!originalPublication || originalPublication !== JSON.stringify(data.publication)) { 467 data.publication ??= { 468 name: getName(data), 469 description: getDescription(data), 470 preferences: { 471 hideProfileSection: getHideProfileSection(data) 472 } 473 }; 474 475 if (!data.publication.url) { 476 data.publication.url = 'https://blento.app/' + data.handle; 477 478 if (data.page !== 'blento.self') { 479 data.publication.url += '/' + data.page.replace('blento.', ''); 480 } 481 } 482 if (data.page !== 'blento.self') { 483 promises.push( 484 putRecord({ 485 collection: 'app.blento.page', 486 rkey: data.page, 487 record: data.publication 488 }) 489 ); 490 } else { 491 promises.push( 492 putRecord({ 493 collection: 'site.standard.publication', 494 rkey: data.page, 495 record: data.publication 496 }) 497 ); 498 } 499 500 console.log('updating or adding publication', data.publication); 501 } 502 503 await Promise.all(promises); 504 505 fetch('/' + data.handle + '/api/refresh').then(() => { 506 console.log('data refreshed!'); 507 }); 508 console.log('refreshing data'); 509 510 toast('Saved', { 511 description: 'Your website has been saved!' 512 }); 513} 514 515export function createEmptyCard(page: string) { 516 return { 517 id: TID.now(), 518 x: 0, 519 y: 0, 520 w: 2, 521 h: 2, 522 mobileH: 4, 523 mobileW: 4, 524 mobileX: 0, 525 mobileY: 0, 526 cardType: '', 527 cardData: {}, 528 page 529 } as Item; 530} 531 532export function scrollToItem( 533 item: Item, 534 isMobile: boolean, 535 container: HTMLDivElement | undefined, 536 force: boolean = false 537) { 538 // scroll to newly created card only if not fully visible 539 const containerRect = container?.getBoundingClientRect(); 540 if (!containerRect) return; 541 const currentMargin = isMobile ? mobileMargin : margin; 542 const currentY = isMobile ? item.mobileY : item.y; 543 const currentH = isMobile ? item.mobileH : item.h; 544 const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS; 545 546 const cardTop = containerRect.top + currentMargin + currentY * cellSize; 547 const cardBottom = containerRect.top + currentMargin + (currentY + currentH) * cellSize; 548 549 const isFullyVisible = cardTop >= 0 && cardBottom <= window.innerHeight; 550 551 if (!isFullyVisible || force) { 552 const bodyRect = document.body.getBoundingClientRect(); 553 const offset = containerRect.top - bodyRect.top; 554 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 555 } 556} 557 558export async function checkAndUploadImage( 559 objectWithImage: Record<string, any>, 560 key: string = 'image' 561) { 562 if (!objectWithImage[key]) return; 563 564 // Already uploaded as blob 565 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 566 return; 567 } 568 569 if (typeof objectWithImage[key] === 'string') { 570 // Download image from URL via proxy (to avoid CORS) and upload as blob 571 try { 572 const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`; 573 const response = await fetch(proxyUrl); 574 if (!response.ok) { 575 console.error('Failed to fetch image:', objectWithImage[key]); 576 return; 577 } 578 const blob = await response.blob(); 579 const compressedBlob = await compressImage(blob); 580 objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 581 } catch (error) { 582 console.error('Failed to download and upload image:', error); 583 } 584 return; 585 } 586 587 if (objectWithImage[key]?.blob) { 588 const compressedBlob = await compressImage(objectWithImage[key].blob); 589 objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 590 } 591} 592 593export function getImage( 594 objectWithImage: Record<string, any> | undefined, 595 did: string, 596 key: string = 'image' 597) { 598 if (!objectWithImage?.[key]) return; 599 600 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 601 602 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 603 return getImageBlobUrl({ did, blob: objectWithImage[key] }); 604 } 605 return objectWithImage[key]; 606}