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