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