your personal website on atproto - mirror blento.app
at profile-stuff 770 lines 20 kB view raw
1<script lang="ts"> 2 import { Button, toast, Toaster, Sidebar } from '@foxui/core'; 3 import { COLUMNS, margin, mobileMargin } from '$lib'; 4 import { 5 checkAndUploadImage, 6 clamp, 7 compactItems, 8 createEmptyCard, 9 fixCollisions, 10 getHideProfileSection, 11 getProfilePosition, 12 getName, 13 isTyping, 14 savePage, 15 scrollToItem, 16 setPositionOfNewItem, 17 validateLink 18 } from '../helper'; 19 import EditableProfile from './EditableProfile.svelte'; 20 import type { Item, WebsiteData } from '../types'; 21 import { innerWidth } from 'svelte/reactivity/window'; 22 import EditingCard from '../cards/Card/EditingCard.svelte'; 23 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 24 import { tick, type Component } from 'svelte'; 25 import type { CreationModalComponentProps } from '../cards/types'; 26 import { dev } from '$app/environment'; 27 import { setIsMobile } from './context'; 28 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 29 import Context from './Context.svelte'; 30 import Head from './Head.svelte'; 31 import { compressImage } from '../helper'; 32 import Account from './Account.svelte'; 33 import EditBar from './EditBar.svelte'; 34 35 let { 36 data 37 }: { 38 data: WebsiteData; 39 } = $props(); 40 41 let imageDragOver = $state(false); 42 43 // svelte-ignore state_referenced_locally 44 let items: Item[] = $state(data.cards); 45 46 // svelte-ignore state_referenced_locally 47 let publication = $state(JSON.stringify(data.publication)); 48 49 let container: HTMLDivElement | undefined = $state(); 50 51 let activeDragElement: { 52 element: HTMLDivElement | null; 53 item: Item | null; 54 w: number; 55 h: number; 56 x: number; 57 y: number; 58 mouseDeltaX: number; 59 mouseDeltaY: number; 60 // For hysteresis - track last decision to prevent flickering 61 lastTargetId: string | null; 62 lastPlacement: 'above' | 'below' | null; 63 // Store original positions to reset from during drag 64 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 65 } = $state({ 66 element: null, 67 item: null, 68 w: 0, 69 h: 0, 70 x: -1, 71 y: -1, 72 mouseDeltaX: 0, 73 mouseDeltaY: 0, 74 lastTargetId: null, 75 lastPlacement: null, 76 originalPositions: new Map() 77 }); 78 79 let showingMobileView = $state(false); 80 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 81 82 setIsMobile(() => isMobile); 83 84 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 85 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 86 87 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 88 89 function newCard(type: string = 'link', cardData?: any) { 90 // close sidebar if open 91 const popover = document.getElementById('mobile-menu'); 92 if (popover) { 93 popover.hidePopover(); 94 } 95 96 let item = createEmptyCard(data.page); 97 item.cardType = type; 98 99 item.cardData = cardData ?? {}; 100 101 const cardDef = CardDefinitionsByType[type]; 102 cardDef?.createNew?.(item); 103 104 newItem.item = item; 105 106 if (cardDef?.creationModalComponent) { 107 newItem.modal = cardDef.creationModalComponent; 108 } else { 109 saveNewItem(); 110 } 111 } 112 113 async function saveNewItem() { 114 if (!newItem.item) return; 115 const item = newItem.item; 116 117 setPositionOfNewItem(item, items); 118 119 items = [...items, item]; 120 121 newItem = {}; 122 123 await tick(); 124 125 scrollToItem(item, isMobile, container); 126 } 127 128 let isSaving = $state(false); 129 130 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 131 132 async function save() { 133 isSaving = true; 134 135 try { 136 // Upload profile icon if changed 137 if (data.publication?.icon) { 138 await checkAndUploadImage(data.publication, 'icon'); 139 } 140 141 await savePage(data, items, publication); 142 143 publication = JSON.stringify(data.publication); 144 } catch { 145 toast.error('Error saving page!'); 146 } finally { 147 isSaving = false; 148 } 149 } 150 151 const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText); 152 153 let debugPoint = $state({ x: 0, y: 0 }); 154 155 function getDragXY( 156 e: DragEvent & { 157 currentTarget: EventTarget & HTMLDivElement; 158 } 159 ): 160 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 161 | undefined { 162 if (!container || !activeDragElement.item) return; 163 164 // x, y represent the top-left corner of the dragged card 165 const x = e.clientX + activeDragElement.mouseDeltaX; 166 const y = e.clientY + activeDragElement.mouseDeltaY; 167 168 const rect = container.getBoundingClientRect(); 169 const currentMargin = isMobile ? mobileMargin : margin; 170 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 171 172 // Get card dimensions based on current view mode 173 const cardW = isMobile 174 ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 175 : activeDragElement.w; 176 const cardH = isMobile 177 ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 178 : activeDragElement.h; 179 180 // Get dragged card's original position 181 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 182 183 const draggedOrigY = draggedOrigPos 184 ? isMobile 185 ? draggedOrigPos.mobileY 186 : draggedOrigPos.y 187 : 0; 188 189 // Calculate raw grid position based on top-left of dragged card 190 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 191 gridX = Math.floor(gridX / 2) * 2; 192 193 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 194 195 if (isMobile) { 196 gridX = Math.floor(gridX / 2) * 2; 197 gridY = Math.floor(gridY / 2) * 2; 198 } 199 200 // Find if we're hovering over another card (using ORIGINAL positions) 201 const centerGridY = gridY + cardH / 2; 202 const centerGridX = gridX + cardW / 2; 203 204 let swapWithId: string | null = null; 205 let placement: 'above' | 'below' | null = null; 206 207 for (const other of items) { 208 if (other === activeDragElement.item) continue; 209 210 // Use original positions for hit testing 211 const origPos = activeDragElement.originalPositions.get(other.id); 212 if (!origPos) continue; 213 214 const otherX = isMobile ? origPos.mobileX : origPos.x; 215 const otherY = isMobile ? origPos.mobileY : origPos.y; 216 const otherW = isMobile ? other.mobileW : other.w; 217 const otherH = isMobile ? other.mobileH : other.h; 218 219 // Check if dragged card's center point is within this card's original bounds 220 if ( 221 centerGridX >= otherX && 222 centerGridX < otherX + otherW && 223 centerGridY >= otherY && 224 centerGridY < otherY + otherH 225 ) { 226 // Check if this is a swap situation: 227 // Cards have the same dimensions and are on the same row 228 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 229 230 if (canSwap) { 231 // Swap positions 232 swapWithId = other.id; 233 gridX = otherX; 234 gridY = otherY; 235 placement = null; 236 237 activeDragElement.lastTargetId = other.id; 238 activeDragElement.lastPlacement = null; 239 } else { 240 // Vertical placement (above/below) 241 // Detect drag direction: if dragging up, always place above 242 const isDraggingUp = gridY < draggedOrigY; 243 244 if (isDraggingUp) { 245 // When dragging up, always place above 246 placement = 'above'; 247 } else { 248 // When dragging down, use top/bottom half logic 249 const midpointY = otherY + otherH / 2; 250 const hysteresis = 0.3; 251 252 if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 253 if (activeDragElement.lastPlacement === 'above') { 254 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 255 } else { 256 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 257 } 258 } else { 259 placement = centerGridY < midpointY ? 'above' : 'below'; 260 } 261 } 262 263 activeDragElement.lastTargetId = other.id; 264 activeDragElement.lastPlacement = placement; 265 266 if (placement === 'above') { 267 gridY = otherY; 268 } else { 269 gridY = otherY + otherH; 270 } 271 } 272 break; 273 } 274 } 275 276 // If we're not over any card, clear the tracking 277 if (!swapWithId && !placement) { 278 activeDragElement.lastTargetId = null; 279 activeDragElement.lastPlacement = null; 280 } 281 282 debugPoint.x = x - rect.left; 283 debugPoint.y = y - rect.top + currentMargin; 284 285 return { x: gridX, y: gridY, swapWithId, placement }; 286 } 287 288 let linkValue = $state(''); 289 290 function addLink(url: string) { 291 let link = validateLink(url); 292 if (!link) { 293 toast.error('invalid link'); 294 return; 295 } 296 let item = createEmptyCard(data.page); 297 298 for (const cardDef of AllCardDefinitions.toSorted( 299 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 300 )) { 301 if (cardDef.onUrlHandler?.(link, item)) { 302 item.cardType = cardDef.type; 303 304 newItem.item = item; 305 saveNewItem(); 306 toast(cardDef.name + ' added!'); 307 break; 308 } 309 } 310 311 if (linkValue === url) { 312 linkValue = ''; 313 } 314 } 315 316 async function processImageFile(file: File, gridX?: number, gridY?: number) { 317 const isGif = file.type === 'image/gif'; 318 319 // Don't compress GIFs to preserve animation 320 const processedFile = isGif ? file : await compressImage(file); 321 const objectUrl = URL.createObjectURL(processedFile); 322 323 let item = createEmptyCard(data.page); 324 325 item.cardType = isGif ? 'gif' : 'image'; 326 item.cardData = { 327 image: { blob: processedFile, objectUrl } 328 }; 329 330 // If grid position is provided 331 if (gridX !== undefined && gridY !== undefined) { 332 if (isMobile) { 333 item.mobileX = gridX; 334 item.mobileY = gridY; 335 } else { 336 item.x = gridX; 337 item.y = gridY; 338 } 339 340 items = [...items, item]; 341 fixCollisions(items, item, isMobile); 342 } else { 343 setPositionOfNewItem(item, items); 344 items = [...items, item]; 345 } 346 347 await tick(); 348 349 scrollToItem(item, isMobile, container); 350 } 351 352 function handleImageDragOver(event: DragEvent) { 353 const dt = event.dataTransfer; 354 if (!dt) return; 355 356 let hasImage = false; 357 if (dt.items) { 358 for (let i = 0; i < dt.items.length; i++) { 359 const item = dt.items[i]; 360 if (item && item.kind === 'file' && item.type.startsWith('image/')) { 361 hasImage = true; 362 break; 363 } 364 } 365 } else if (dt.files) { 366 for (let i = 0; i < dt.files.length; i++) { 367 const file = dt.files[i]; 368 if (file?.type.startsWith('image/')) { 369 hasImage = true; 370 break; 371 } 372 } 373 } 374 375 if (hasImage) { 376 event.preventDefault(); 377 event.stopPropagation(); 378 379 imageDragOver = true; 380 } 381 } 382 383 function handleImageDragLeave(event: DragEvent) { 384 event.preventDefault(); 385 event.stopPropagation(); 386 imageDragOver = false; 387 } 388 389 async function handleImageDrop(event: DragEvent) { 390 event.preventDefault(); 391 event.stopPropagation(); 392 const dropX = event.clientX; 393 const dropY = event.clientY; 394 imageDragOver = false; 395 396 if (!event.dataTransfer?.files?.length) return; 397 398 const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 399 f?.type.startsWith('image/') 400 ); 401 if (imageFiles.length === 0) return; 402 403 // Calculate starting grid position from drop coordinates 404 let gridX = 0; 405 let gridY = 0; 406 if (container) { 407 const rect = container.getBoundingClientRect(); 408 const currentMargin = isMobile ? mobileMargin : margin; 409 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 410 const cardW = isMobile ? 4 : 2; 411 412 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 413 gridX = Math.floor(gridX / 2) * 2; 414 415 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 416 if (isMobile) { 417 gridY = Math.floor(gridY / 2) * 2; 418 } 419 } 420 421 for (const file of imageFiles) { 422 await processImageFile(file, gridX, gridY); 423 424 // Move to next cell position 425 const cardW = isMobile ? 4 : 2; 426 gridX += cardW; 427 if (gridX + cardW > COLUMNS) { 428 gridX = 0; 429 gridY += isMobile ? 4 : 2; 430 } 431 } 432 } 433 434 async function handleImageInputChange(event: Event) { 435 const target = event.target as HTMLInputElement; 436 if (!target.files || target.files.length < 1) return; 437 438 const files = Array.from(target.files); 439 440 if (files.length === 1) { 441 // Single file: use default positioning 442 await processImageFile(files[0]); 443 } else { 444 // Multiple files: place in grid pattern starting from first available position 445 let gridX = 0; 446 let gridY = maxHeight; 447 const cardW = isMobile ? 4 : 2; 448 const cardH = isMobile ? 4 : 2; 449 450 for (const file of files) { 451 await processImageFile(file, gridX, gridY); 452 453 // Move to next cell position 454 gridX += cardW; 455 if (gridX + cardW > COLUMNS) { 456 gridX = 0; 457 gridY += cardH; 458 } 459 } 460 } 461 462 // Reset the input so the same file can be selected again 463 target.value = ''; 464 } 465 466 async function processVideoFile(file: File) { 467 const objectUrl = URL.createObjectURL(file); 468 469 let item = createEmptyCard(data.page); 470 471 item.cardType = 'video'; 472 item.cardData = { 473 blob: file, 474 objectUrl 475 }; 476 477 setPositionOfNewItem(item, items); 478 items = [...items, item]; 479 480 await tick(); 481 482 scrollToItem(item, isMobile, container); 483 } 484 485 async function handleVideoInputChange(event: Event) { 486 const target = event.target as HTMLInputElement; 487 if (!target.files || target.files.length < 1) return; 488 489 const files = Array.from(target.files); 490 491 for (const file of files) { 492 await processVideoFile(file); 493 } 494 495 // Reset the input so the same file can be selected again 496 target.value = ''; 497 } 498 499 $inspect(items); 500</script> 501 502<svelte:body 503 onpaste={(event) => { 504 if (isTyping()) return; 505 506 const text = event.clipboardData?.getData('text/plain'); 507 const link = validateLink(text, false); 508 if (!link) return; 509 510 addLink(link); 511 }} 512/> 513 514<svelte:window 515 ondragover={handleImageDragOver} 516 ondragleave={handleImageDragLeave} 517 ondrop={handleImageDrop} 518/> 519 520<Head 521 favicon={data.profile.avatar ?? null} 522 title={getName(data)} 523 image={'/' + data.handle + '/og.png'} 524/> 525 526<Account {data} /> 527 528<Context {data}> 529 {#if !dev} 530 <div 531 class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 532 > 533 Editing on mobile is not supported yet. Please use a desktop browser. 534 </div> 535 {/if} 536 537 {#if getHideProfileSection(data)} 538 <Button 539 size="sm" 540 variant="ghost" 541 onclick={() => { 542 data.publication.preferences ??= {}; 543 data.publication.preferences.hideProfileSection = false; 544 data = { ...data }; 545 }} 546 class="absolute top-14 left-4 z-20" 547 > 548 show profile 549 </Button> 550 {/if} 551 {#if showingMobileView} 552 <div 553 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 554 ></div> 555 {/if} 556 557 {#if newItem.modal && newItem.item} 558 <newItem.modal 559 oncreate={() => { 560 saveNewItem(); 561 }} 562 bind:item={newItem.item} 563 oncancel={() => { 564 newItem = {}; 565 }} 566 /> 567 {/if} 568 569 <div 570 class={[ 571 '@container/wrapper relative w-full', 572 showingMobileView 573 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 574 : '' 575 ]} 576 > 577 {#if !getHideProfileSection(data)} 578 <EditableProfile bind:data /> 579 {/if} 580 581 <div 582 class={[ 583 'mx-auto max-w-lg', 584 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 585 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 586 : '@5xl/wrapper:max-w-4xl' 587 ]} 588 > 589 <div></div> 590 <!-- svelte-ignore a11y_no_static_element_interactions --> 591 <div 592 bind:this={container} 593 ondragover={(e) => { 594 e.preventDefault(); 595 596 const result = getDragXY(e); 597 if (!result) return; 598 599 activeDragElement.x = result.x; 600 activeDragElement.y = result.y; 601 602 if (activeDragElement.item) { 603 // Get dragged card's original position for swapping 604 const draggedOrigPos = activeDragElement.originalPositions.get( 605 activeDragElement.item.id 606 ); 607 608 // Reset all items to original positions first 609 for (const it of items) { 610 const origPos = activeDragElement.originalPositions.get(it.id); 611 if (origPos && it !== activeDragElement.item) { 612 if (isMobile) { 613 it.mobileX = origPos.mobileX; 614 it.mobileY = origPos.mobileY; 615 } else { 616 it.x = origPos.x; 617 it.y = origPos.y; 618 } 619 } 620 } 621 622 // Update dragged item position 623 if (isMobile) { 624 activeDragElement.item.mobileX = result.x; 625 activeDragElement.item.mobileY = result.y; 626 } else { 627 activeDragElement.item.x = result.x; 628 activeDragElement.item.y = result.y; 629 } 630 631 // Handle horizontal swap 632 if (result.swapWithId && draggedOrigPos) { 633 const swapTarget = items.find((it) => it.id === result.swapWithId); 634 if (swapTarget) { 635 // Move swap target to dragged card's original position 636 if (isMobile) { 637 swapTarget.mobileX = draggedOrigPos.mobileX; 638 swapTarget.mobileY = draggedOrigPos.mobileY; 639 } else { 640 swapTarget.x = draggedOrigPos.x; 641 swapTarget.y = draggedOrigPos.y; 642 } 643 } 644 } 645 646 // Now fix collisions (with compacting) 647 fixCollisions(items, activeDragElement.item, isMobile); 648 } 649 650 // Auto-scroll when dragging near top or bottom of viewport 651 const scrollZone = 100; 652 const scrollSpeed = 10; 653 const viewportHeight = window.innerHeight; 654 655 if (e.clientY < scrollZone) { 656 // Near top - scroll up 657 const intensity = 1 - e.clientY / scrollZone; 658 window.scrollBy(0, -scrollSpeed * intensity); 659 } else if (e.clientY > viewportHeight - scrollZone) { 660 // Near bottom - scroll down 661 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 662 window.scrollBy(0, scrollSpeed * intensity); 663 } 664 }} 665 ondragend={async (e) => { 666 e.preventDefault(); 667 const cell = getDragXY(e); 668 if (!cell) return; 669 670 if (activeDragElement.item) { 671 if (isMobile) { 672 activeDragElement.item.mobileX = cell.x; 673 activeDragElement.item.mobileY = cell.y; 674 } else { 675 activeDragElement.item.x = cell.x; 676 activeDragElement.item.y = cell.y; 677 } 678 679 // Fix collisions and compact items after drag ends 680 fixCollisions(items, activeDragElement.item, isMobile); 681 } 682 activeDragElement.x = -1; 683 activeDragElement.y = -1; 684 activeDragElement.element = null; 685 activeDragElement.item = null; 686 activeDragElement.lastTargetId = null; 687 activeDragElement.lastPlacement = null; 688 return true; 689 }} 690 class={[ 691 '@container/grid relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 692 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 693 ]} 694 > 695 {#each items as item, i (item.id)} 696 <!-- {#if item !== activeDragElement.item} --> 697 <BaseEditingCard 698 bind:item={items[i]} 699 ondelete={() => { 700 items = items.filter((it) => it !== item); 701 compactItems(items, isMobile); 702 }} 703 onsetsize={(newW: number, newH: number) => { 704 if (isMobile) { 705 item.mobileW = newW; 706 item.mobileH = newH; 707 } else { 708 item.w = newW; 709 item.h = newH; 710 } 711 712 fixCollisions(items, item, isMobile); 713 }} 714 ondragstart={(e: DragEvent) => { 715 const target = e.currentTarget as HTMLDivElement; 716 activeDragElement.element = target; 717 activeDragElement.w = item.w; 718 activeDragElement.h = item.h; 719 activeDragElement.item = item; 720 721 // Store original positions of all items 722 activeDragElement.originalPositions = new Map(); 723 for (const it of items) { 724 activeDragElement.originalPositions.set(it.id, { 725 x: it.x, 726 y: it.y, 727 mobileX: it.mobileX, 728 mobileY: it.mobileY 729 }); 730 } 731 732 const rect = target.getBoundingClientRect(); 733 activeDragElement.mouseDeltaX = rect.left - e.clientX; 734 activeDragElement.mouseDeltaY = rect.top - e.clientY; 735 }} 736 > 737 <EditingCard bind:item={items[i]} /> 738 </BaseEditingCard> 739 <!-- {/if} --> 740 {/each} 741 742 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 743 </div> 744 </div> 745 </div> 746 747 <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 748 <div class="flex flex-col gap-2"> 749 {#each sidebarItems as cardDef (cardDef.type)} 750 <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 751 >{cardDef.sidebarButtonText}</Button 752 > 753 {/each} 754 </div> 755 </Sidebar> 756 757 <EditBar 758 {data} 759 bind:linkValue 760 bind:isSaving 761 bind:showingMobileView 762 {newCard} 763 {addLink} 764 {save} 765 {handleImageInputChange} 766 {handleVideoInputChange} 767 /> 768 769 <Toaster /> 770</Context>