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