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