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