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