your personal website on atproto - mirror blento.app
at custom-domains 732 lines 20 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 (const item of sortedItems) { 184 // Try moving item up row by row until we hit y=0 or a collision 185 while (true) { 186 const currentY = mobile ? item.mobileY : item.y; 187 if (currentY <= 0) break; 188 189 // Temporarily move up by 1 190 if (mobile) item.mobileY -= 1; 191 else item.y -= 1; 192 193 // Check for collision with any other item 194 const hasCollision = items.some((other) => other !== item && overlaps(item, other, mobile)); 195 196 if (hasCollision) { 197 // Revert the move 198 if (mobile) item.mobileY += 1; 199 else item.y += 1; 200 break; 201 } 202 // No collision, keep the new position and try moving up again 203 } 204 } 205} 206 207// Simulate where an item would end up after fixCollisions + compaction 208export function simulateFinalPosition( 209 items: Item[], 210 movedItem: Item, 211 newX: number, 212 newY: number, 213 mobile: boolean = false 214): { x: number; y: number } { 215 // Deep clone positions for simulation 216 const clonedItems: Item[] = items.map((item) => ({ 217 ...item, 218 x: item.x, 219 y: item.y, 220 mobileX: item.mobileX, 221 mobileY: item.mobileY 222 })); 223 224 const clonedMovedItem = clonedItems.find((item) => item.id === movedItem.id); 225 if (!clonedMovedItem) return { x: newX, y: newY }; 226 227 // Set the new position 228 if (mobile) { 229 clonedMovedItem.mobileX = newX; 230 clonedMovedItem.mobileY = newY; 231 } else { 232 clonedMovedItem.x = newX; 233 clonedMovedItem.y = newY; 234 } 235 236 // Run fixCollisions on the cloned data 237 fixCollisions(clonedItems, clonedMovedItem, mobile); 238 239 // Return the final position of the moved item 240 return mobile 241 ? { x: clonedMovedItem.mobileX, y: clonedMovedItem.mobileY } 242 : { x: clonedMovedItem.x, y: clonedMovedItem.y }; 243} 244 245export function sortItems(a: Item, b: Item) { 246 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; 247} 248 249export function cardsEqual(a: Item, b: Item) { 250 return ( 251 a.id === b.id && 252 a.cardType === b.cardType && 253 JSON.stringify(a.cardData) === JSON.stringify(b.cardData) && 254 a.w === b.w && 255 a.h === b.h && 256 a.mobileW === b.mobileW && 257 a.mobileH === b.mobileH && 258 a.x === b.x && 259 a.y === b.y && 260 a.mobileX === b.mobileX && 261 a.mobileY === b.mobileY && 262 a.color === b.color && 263 a.page === b.page 264 ); 265} 266 267export function setPositionOfNewItem( 268 newItem: Item, 269 items: Item[], 270 viewportCenter?: { gridY: number; isMobile: boolean } 271) { 272 if (viewportCenter) { 273 const { gridY, isMobile } = viewportCenter; 274 275 if (isMobile) { 276 // Place at viewport center Y 277 newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 278 newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 279 280 // Try to find a free X at this Y 281 let found = false; 282 for ( 283 newItem.mobileX = 0; 284 newItem.mobileX <= COLUMNS - newItem.mobileW; 285 newItem.mobileX += 2 286 ) { 287 if (!items.some((item) => overlaps(newItem, item, true))) { 288 found = true; 289 break; 290 } 291 } 292 if (!found) { 293 newItem.mobileX = 0; 294 } 295 296 // Desktop: derive from mobile 297 newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 298 found = false; 299 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 300 if (!items.some((item) => overlaps(newItem, item, false))) { 301 found = true; 302 break; 303 } 304 } 305 if (!found) { 306 newItem.x = 0; 307 } 308 } else { 309 // Place at viewport center Y 310 newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 311 312 // Try to find a free X at this Y 313 let found = false; 314 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 315 if (!items.some((item) => overlaps(newItem, item, false))) { 316 found = true; 317 break; 318 } 319 } 320 if (!found) { 321 newItem.x = 0; 322 } 323 324 // Mobile: derive from desktop 325 newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 326 found = false; 327 for ( 328 newItem.mobileX = 0; 329 newItem.mobileX <= COLUMNS - newItem.mobileW; 330 newItem.mobileX += 2 331 ) { 332 if (!items.some((item) => overlaps(newItem, item, true))) { 333 found = true; 334 break; 335 } 336 } 337 if (!found) { 338 newItem.mobileX = 0; 339 } 340 } 341 return; 342 } 343 344 let foundPosition = false; 345 while (!foundPosition) { 346 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 347 const collision = items.find((item) => overlaps(newItem, item)); 348 if (!collision) { 349 foundPosition = true; 350 break; 351 } 352 } 353 if (!foundPosition) newItem.y += 1; 354 } 355 356 let foundMobilePosition = false; 357 while (!foundMobilePosition) { 358 for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { 359 const collision = items.find((item) => overlaps(newItem, item, true)); 360 361 if (!collision) { 362 foundMobilePosition = true; 363 break; 364 } 365 } 366 if (!foundMobilePosition) newItem.mobileY! += 1; 367 } 368} 369 370/** 371 * Find a valid position for a new item in a single mode (desktop or mobile). 372 * This modifies the item's position properties in-place. 373 */ 374export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { 375 if (mobile) { 376 let foundPosition = false; 377 newItem.mobileY = 0; 378 while (!foundPosition) { 379 for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { 380 const collision = items.find((item) => overlaps(newItem, item, true)); 381 if (!collision) { 382 foundPosition = true; 383 break; 384 } 385 } 386 if (!foundPosition) newItem.mobileY! += 1; 387 } 388 } else { 389 let foundPosition = false; 390 newItem.y = 0; 391 while (!foundPosition) { 392 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 393 const collision = items.find((item) => overlaps(newItem, item, false)); 394 if (!collision) { 395 foundPosition = true; 396 break; 397 } 398 } 399 if (!foundPosition) newItem.y += 1; 400 } 401 } 402} 403 404export async function refreshData(data: { updatedAt?: number; handle: string }) { 405 const TEN_MINUTES = 10 * 60 * 1000; 406 const now = Date.now(); 407 408 if (now - (data.updatedAt || 0) > TEN_MINUTES) { 409 try { 410 await fetch('/' + data.handle + '/api/refresh'); 411 console.log('successfully refreshed data', data.handle); 412 } catch (error) { 413 console.error('error refreshing data', error); 414 } 415 } else { 416 console.log('data still fresh, skipping refreshing', data.handle); 417 } 418} 419 420export function getName(data: WebsiteData): string { 421 return data.publication?.name || data.profile.displayName || data.handle; 422} 423 424export function getDescription(data: WebsiteData): string { 425 return data.publication?.description ?? data.profile.description ?? ''; 426} 427 428export function getHideProfileSection(data: WebsiteData): boolean { 429 if (data?.publication?.preferences?.hideProfileSection !== undefined) 430 return data?.publication?.preferences?.hideProfileSection; 431 432 if (data?.publication?.preferences?.hideProfile !== undefined) 433 return data?.publication?.preferences?.hideProfile; 434 435 return data.page !== 'blento.self'; 436} 437 438export function getProfilePosition(data: WebsiteData): 'side' | 'top' { 439 return data?.publication?.preferences?.profilePosition ?? 'side'; 440} 441 442export function isTyping() { 443 const active = document.activeElement; 444 445 const isEditable = 446 active instanceof HTMLInputElement || 447 active instanceof HTMLTextAreaElement || 448 // @ts-expect-error this fine 449 active?.isContentEditable; 450 451 return isEditable; 452} 453 454export function validateLink( 455 link: string | undefined, 456 tryAdding: boolean = true 457): string | undefined { 458 if (!link) return; 459 try { 460 new URL(link); 461 462 return link; 463 } catch (e) { 464 if (!tryAdding) return; 465 466 try { 467 link = 'https://' + link; 468 new URL(link); 469 470 return link; 471 } catch (e) { 472 return; 473 } 474 } 475} 476 477export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> { 478 return new Promise((resolve, reject) => { 479 const img = new Image(); 480 const reader = new FileReader(); 481 482 reader.onload = (e) => { 483 if (!e.target?.result) { 484 return reject(new Error('Failed to read file.')); 485 } 486 img.src = e.target.result as string; 487 }; 488 489 reader.onerror = (err) => reject(err); 490 reader.readAsDataURL(file); 491 492 img.onload = () => { 493 const maxDimension = 2048; 494 495 // If image is already small enough, return original 496 if (file.size <= maxSize) { 497 console.log('skipping compression+resizing, already small enough'); 498 return resolve(file); 499 } 500 501 let width = img.width; 502 let height = img.height; 503 504 if (width > maxDimension || height > maxDimension) { 505 if (width > height) { 506 height = Math.round((maxDimension / width) * height); 507 width = maxDimension; 508 } else { 509 width = Math.round((maxDimension / height) * width); 510 height = maxDimension; 511 } 512 } 513 514 // Create a canvas to draw the image 515 const canvas = document.createElement('canvas'); 516 canvas.width = width; 517 canvas.height = height; 518 const ctx = canvas.getContext('2d'); 519 if (!ctx) return reject(new Error('Failed to get canvas context.')); 520 ctx.drawImage(img, 0, 0, width, height); 521 522 // Use WebP for both compression and transparency support 523 let quality = 0.9; 524 525 function attemptCompression() { 526 canvas.toBlob( 527 (blob) => { 528 if (!blob) { 529 return reject(new Error('Compression failed.')); 530 } 531 if (blob.size <= maxSize || quality < 0.3) { 532 resolve(blob); 533 } else { 534 quality -= 0.1; 535 attemptCompression(); 536 } 537 }, 538 'image/webp', 539 quality 540 ); 541 } 542 543 attemptCompression(); 544 }; 545 546 img.onerror = (err) => reject(err); 547 }); 548} 549 550export async function savePage( 551 data: WebsiteData, 552 currentItems: Item[], 553 originalPublication: string 554) { 555 const promises = []; 556 // find all cards that have been updated (where items differ from originalItems) 557 for (let item of currentItems) { 558 const originalItem = data.cards.find((i) => cardsEqual(i, item)); 559 560 if (!originalItem) { 561 console.log('updated or new item', item); 562 item.updatedAt = new Date().toISOString(); 563 // run optional upload function for this card type 564 const cardDef = CardDefinitionsByType[item.cardType]; 565 566 if (cardDef?.upload) { 567 item = await cardDef?.upload(item); 568 } 569 570 const parsedItem = JSON.parse(JSON.stringify(item)); 571 572 parsedItem.page = data.page; 573 parsedItem.version = 2; 574 575 promises.push( 576 putRecord({ 577 collection: 'app.blento.card', 578 rkey: parsedItem.id, 579 record: parsedItem 580 }) 581 ); 582 } 583 } 584 585 // delete items that are in originalItems but not in items 586 for (const originalItem of data.cards) { 587 const item = currentItems.find((i) => i.id === originalItem.id); 588 if (!item) { 589 console.log('deleting item', originalItem); 590 promises.push(deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id })); 591 } 592 } 593 594 if ( 595 data.publication?.preferences?.hideProfile !== undefined && 596 data.publication?.preferences?.hideProfileSection === undefined 597 ) { 598 data.publication.preferences.hideProfileSection = data.publication?.preferences?.hideProfile; 599 } 600 601 if (!originalPublication || originalPublication !== JSON.stringify(data.publication)) { 602 data.publication ??= { 603 name: getName(data), 604 description: getDescription(data), 605 preferences: { 606 hideProfileSection: getHideProfileSection(data) 607 } 608 }; 609 610 if (!data.publication.url) { 611 data.publication.url = 'https://blento.app/' + data.handle; 612 613 if (data.page !== 'blento.self') { 614 data.publication.url += '/' + data.page.replace('blento.', ''); 615 } 616 } 617 if (data.page !== 'blento.self') { 618 promises.push( 619 putRecord({ 620 collection: 'app.blento.page', 621 rkey: data.page, 622 record: data.publication 623 }) 624 ); 625 } else { 626 promises.push( 627 putRecord({ 628 collection: 'site.standard.publication', 629 rkey: data.page, 630 record: data.publication 631 }) 632 ); 633 } 634 635 console.log('updating or adding publication', data.publication); 636 } 637 638 await Promise.all(promises); 639} 640 641export function createEmptyCard(page: string) { 642 return { 643 id: TID.now(), 644 x: 0, 645 y: 0, 646 w: 2, 647 h: 2, 648 mobileH: 4, 649 mobileW: 4, 650 mobileX: 0, 651 mobileY: 0, 652 cardType: '', 653 cardData: {}, 654 page 655 } as Item; 656} 657 658export function scrollToItem( 659 item: Item, 660 isMobile: boolean, 661 container: HTMLDivElement | undefined, 662 force: boolean = false 663) { 664 // scroll to newly created card only if not fully visible 665 const containerRect = container?.getBoundingClientRect(); 666 if (!containerRect) return; 667 const currentMargin = isMobile ? mobileMargin : margin; 668 const currentY = isMobile ? item.mobileY : item.y; 669 const currentH = isMobile ? item.mobileH : item.h; 670 const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS; 671 672 const cardTop = containerRect.top + currentMargin + currentY * cellSize; 673 const cardBottom = containerRect.top + currentMargin + (currentY + currentH) * cellSize; 674 675 const isFullyVisible = cardTop >= 0 && cardBottom <= window.innerHeight; 676 677 if (!isFullyVisible || force) { 678 const bodyRect = document.body.getBoundingClientRect(); 679 const offset = containerRect.top - bodyRect.top; 680 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 681 } 682} 683 684export async function checkAndUploadImage( 685 objectWithImage: Record<string, any>, 686 key: string = 'image' 687) { 688 if (!objectWithImage[key]) return; 689 690 // Already uploaded as blob 691 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 692 return; 693 } 694 695 if (typeof objectWithImage[key] === 'string') { 696 // Download image from URL via proxy (to avoid CORS) and upload as blob 697 try { 698 const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`; 699 const response = await fetch(proxyUrl); 700 if (!response.ok) { 701 console.error('Failed to fetch image:', objectWithImage[key]); 702 return; 703 } 704 const blob = await response.blob(); 705 const compressedBlob = await compressImage(blob); 706 objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 707 } catch (error) { 708 console.error('Failed to download and upload image:', error); 709 } 710 return; 711 } 712 713 if (objectWithImage[key]?.blob) { 714 const compressedBlob = await compressImage(objectWithImage[key].blob); 715 objectWithImage[key] = await uploadBlob({ blob: compressedBlob }); 716 } 717} 718 719export function getImage( 720 objectWithImage: Record<string, any> | undefined, 721 did: string, 722 key: string = 'image' 723) { 724 if (!objectWithImage?.[key]) return; 725 726 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl; 727 728 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') { 729 return getCDNImageBlobUrl({ did, blob: objectWithImage[key] }); 730 } 731 return objectWithImage[key]; 732}