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