your personal website on atproto - mirror blento.app
at mobile-editing 1151 lines 32 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 getImage 20 } from '../helper'; 21 import EditableProfile from './EditableProfile.svelte'; 22 import type { Item, WebsiteData } from '../types'; 23 import { innerWidth } from 'svelte/reactivity/window'; 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 import { tick, type Component } from 'svelte'; 27 import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 28 import { dev } from '$app/environment'; 29 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 import Context from './Context.svelte'; 32 import Head from './Head.svelte'; 33 import Account from './Account.svelte'; 34 import { SelectThemePopover } from '$lib/components/select-theme'; 35 import EditBar from './EditBar.svelte'; 36 import SaveModal from './SaveModal.svelte'; 37 import FloatingEditButton from './FloatingEditButton.svelte'; 38 import { user } from '$lib/atproto'; 39 import { launchConfetti } from '@foxui/visual'; 40 import Controls from './Controls.svelte'; 41 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 42 import { shouldMirror, mirrorLayout } from './layout-mirror'; 43 44 let { 45 data 46 }: { 47 data: WebsiteData; 48 } = $props(); 49 50 // Check if floating login button will be visible (to hide MadeWithBlento) 51 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 52 53 function updateTheme(newAccent: string, newBase: string) { 54 data.publication.preferences ??= {}; 55 data.publication.preferences.accentColor = newAccent; 56 data.publication.preferences.baseColor = newBase; 57 data = { ...data }; 58 } 59 60 let imageDragOver = $state(false); 61 62 // svelte-ignore state_referenced_locally 63 let items: Item[] = $state(data.cards); 64 65 // svelte-ignore state_referenced_locally 66 let publication = $state(JSON.stringify(data.publication)); 67 68 // Track saved state for comparison 69 // svelte-ignore state_referenced_locally 70 let savedItems = $state(JSON.stringify(data.cards)); 71 // svelte-ignore state_referenced_locally 72 let savedPublication = $state(JSON.stringify(data.publication)); 73 74 let hasUnsavedChanges = $derived( 75 JSON.stringify(items) !== savedItems || JSON.stringify(data.publication) !== savedPublication 76 ); 77 78 // Warn user before closing tab if there are unsaved changes 79 $effect(() => { 80 function handleBeforeUnload(e: BeforeUnloadEvent) { 81 if (hasUnsavedChanges) { 82 e.preventDefault(); 83 return ''; 84 } 85 } 86 87 window.addEventListener('beforeunload', handleBeforeUnload); 88 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 89 }); 90 91 let container: HTMLDivElement | undefined = $state(); 92 93 let activeDragElement: { 94 element: HTMLDivElement | null; 95 item: Item | null; 96 w: number; 97 h: number; 98 x: number; 99 y: number; 100 mouseDeltaX: number; 101 mouseDeltaY: number; 102 // For hysteresis - track last decision to prevent flickering 103 lastTargetId: string | null; 104 lastPlacement: 'above' | 'below' | null; 105 // Store original positions to reset from during drag 106 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 107 } = $state({ 108 element: null, 109 item: null, 110 w: 0, 111 h: 0, 112 x: -1, 113 y: -1, 114 mouseDeltaX: 0, 115 mouseDeltaY: 0, 116 lastTargetId: null, 117 lastPlacement: null, 118 originalPositions: new Map() 119 }); 120 121 let showingMobileView = $state(false); 122 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 123 124 setIsMobile(() => isMobile); 125 126 // svelte-ignore state_referenced_locally 127 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 128 129 function onLayoutChanged() { 130 // Set the bit for the current layout: desktop=1, mobile=2 131 editedOn = editedOn | (isMobile ? 2 : 1); 132 if (shouldMirror(editedOn)) { 133 mirrorLayout(items, isMobile); 134 } 135 } 136 137 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 138 setIsCoarse(() => isCoarse); 139 140 let selectedCardId: string | null = $state(null); 141 let selectedCard = $derived( 142 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 143 ); 144 145 setSelectedCardId(() => selectedCardId); 146 setSelectCard((id: string | null) => { 147 selectedCardId = id; 148 }); 149 150 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 151 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 152 153 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 154 155 function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 156 if (!container) return undefined; 157 const rect = container.getBoundingClientRect(); 158 const currentMargin = isMobile ? mobileMargin : margin; 159 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 160 const viewportCenterY = window.innerHeight / 2; 161 const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 162 return { gridY, isMobile }; 163 } 164 165 function newCard(type: string = 'link', cardData?: any) { 166 selectedCardId = null; 167 168 // close sidebar if open 169 const popover = document.getElementById('mobile-menu'); 170 if (popover) { 171 popover.hidePopover(); 172 } 173 174 let item = createEmptyCard(data.page); 175 item.cardType = type; 176 177 item.cardData = cardData ?? {}; 178 179 const cardDef = CardDefinitionsByType[type]; 180 cardDef?.createNew?.(item); 181 182 newItem.item = item; 183 184 if (cardDef?.creationModalComponent) { 185 newItem.modal = cardDef.creationModalComponent; 186 } else { 187 saveNewItem(); 188 } 189 } 190 191 async function saveNewItem() { 192 if (!newItem.item) return; 193 const item = newItem.item; 194 195 const viewportCenter = getViewportCenterGridY(); 196 setPositionOfNewItem(item, items, viewportCenter); 197 198 items = [...items, item]; 199 200 // Push overlapping items down, then compact to fill gaps 201 fixCollisions(items, item, false, true); 202 fixCollisions(items, item, true, true); 203 compactItems(items, false); 204 compactItems(items, true); 205 206 onLayoutChanged(); 207 208 newItem = {}; 209 210 await tick(); 211 212 scrollToItem(item, isMobile, container); 213 } 214 215 let isSaving = $state(false); 216 let showSaveModal = $state(false); 217 let saveSuccess = $state(false); 218 219 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 220 221 async function save() { 222 isSaving = true; 223 saveSuccess = false; 224 showSaveModal = true; 225 226 try { 227 // Upload profile icon if changed 228 if (data.publication?.icon) { 229 await checkAndUploadImage(data.publication, 'icon'); 230 } 231 232 // Persist layout editing state 233 data.publication.preferences ??= {}; 234 data.publication.preferences.editedOn = editedOn; 235 236 await savePage(data, items, publication); 237 238 publication = JSON.stringify(data.publication); 239 240 // Update saved state 241 savedItems = JSON.stringify(items); 242 savedPublication = JSON.stringify(data.publication); 243 244 saveSuccess = true; 245 246 launchConfetti(); 247 248 // Refresh cached data 249 await fetch('/' + data.handle + '/api/refresh'); 250 } catch (error) { 251 console.log(error); 252 showSaveModal = false; 253 toast.error('Error saving page!'); 254 } finally { 255 isSaving = false; 256 } 257 } 258 259 const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText); 260 261 let debugPoint = $state({ x: 0, y: 0 }); 262 263 function getGridPosition( 264 clientX: number, 265 clientY: number 266 ): 267 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 268 | undefined { 269 if (!container || !activeDragElement.item) return; 270 271 // x, y represent the top-left corner of the dragged card 272 const x = clientX + activeDragElement.mouseDeltaX; 273 const y = clientY + activeDragElement.mouseDeltaY; 274 275 const rect = container.getBoundingClientRect(); 276 const currentMargin = isMobile ? mobileMargin : margin; 277 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 278 279 // Get card dimensions based on current view mode 280 const cardW = isMobile 281 ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 282 : activeDragElement.w; 283 const cardH = isMobile 284 ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 285 : activeDragElement.h; 286 287 // Get dragged card's original position 288 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 289 290 const draggedOrigY = draggedOrigPos 291 ? isMobile 292 ? draggedOrigPos.mobileY 293 : draggedOrigPos.y 294 : 0; 295 296 // Calculate raw grid position based on top-left of dragged card 297 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 298 gridX = Math.floor(gridX / 2) * 2; 299 300 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 301 302 if (isMobile) { 303 gridX = Math.floor(gridX / 2) * 2; 304 gridY = Math.floor(gridY / 2) * 2; 305 } 306 307 // Find if we're hovering over another card (using ORIGINAL positions) 308 const centerGridY = gridY + cardH / 2; 309 const centerGridX = gridX + cardW / 2; 310 311 let swapWithId: string | null = null; 312 let placement: 'above' | 'below' | null = null; 313 314 for (const other of items) { 315 if (other === activeDragElement.item) continue; 316 317 // Use original positions for hit testing 318 const origPos = activeDragElement.originalPositions.get(other.id); 319 if (!origPos) continue; 320 321 const otherX = isMobile ? origPos.mobileX : origPos.x; 322 const otherY = isMobile ? origPos.mobileY : origPos.y; 323 const otherW = isMobile ? other.mobileW : other.w; 324 const otherH = isMobile ? other.mobileH : other.h; 325 326 // Check if dragged card's center point is within this card's original bounds 327 if ( 328 centerGridX >= otherX && 329 centerGridX < otherX + otherW && 330 centerGridY >= otherY && 331 centerGridY < otherY + otherH 332 ) { 333 // Check if this is a swap situation: 334 // Cards have the same dimensions and are on the same row 335 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 336 337 if (canSwap) { 338 // Swap positions 339 swapWithId = other.id; 340 gridX = otherX; 341 gridY = otherY; 342 placement = null; 343 344 activeDragElement.lastTargetId = other.id; 345 activeDragElement.lastPlacement = null; 346 } else { 347 // Vertical placement (above/below) 348 // Detect drag direction: if dragging up, always place above 349 const isDraggingUp = gridY < draggedOrigY; 350 351 if (isDraggingUp) { 352 // When dragging up, always place above 353 placement = 'above'; 354 } else { 355 // When dragging down, use top/bottom half logic 356 const midpointY = otherY + otherH / 2; 357 const hysteresis = 0.3; 358 359 if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 360 if (activeDragElement.lastPlacement === 'above') { 361 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 362 } else { 363 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 364 } 365 } else { 366 placement = centerGridY < midpointY ? 'above' : 'below'; 367 } 368 } 369 370 activeDragElement.lastTargetId = other.id; 371 activeDragElement.lastPlacement = placement; 372 373 if (placement === 'above') { 374 gridY = otherY; 375 } else { 376 gridY = otherY + otherH; 377 } 378 } 379 break; 380 } 381 } 382 383 // If we're not over any card, clear the tracking 384 if (!swapWithId && !placement) { 385 activeDragElement.lastTargetId = null; 386 activeDragElement.lastPlacement = null; 387 } 388 389 debugPoint.x = x - rect.left; 390 debugPoint.y = y - rect.top + currentMargin; 391 392 return { x: gridX, y: gridY, swapWithId, placement }; 393 } 394 395 function getDragXY( 396 e: DragEvent & { 397 currentTarget: EventTarget & HTMLDivElement; 398 } 399 ) { 400 return getGridPosition(e.clientX, e.clientY); 401 } 402 403 // Touch drag system (instant drag on selected card) 404 let touchDragActive = $state(false); 405 406 function touchStart(e: TouchEvent) { 407 if (!selectedCardId || !container) return; 408 const touch = e.touches[0]; 409 if (!touch) return; 410 411 // Check if the touch is on the selected card element 412 const target = (e.target as HTMLElement)?.closest?.('.card'); 413 if (!target || target.id !== selectedCardId) return; 414 415 const item = items.find((i) => i.id === selectedCardId); 416 if (!item || item.cardData?.locked) return; 417 418 // Start dragging immediately 419 touchDragActive = true; 420 421 const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 422 if (!cardEl) return; 423 424 activeDragElement.element = cardEl; 425 activeDragElement.w = item.w; 426 activeDragElement.h = item.h; 427 activeDragElement.item = item; 428 429 // Store original positions of all items 430 activeDragElement.originalPositions = new Map(); 431 for (const it of items) { 432 activeDragElement.originalPositions.set(it.id, { 433 x: it.x, 434 y: it.y, 435 mobileX: it.mobileX, 436 mobileY: it.mobileY 437 }); 438 } 439 440 const rect = cardEl.getBoundingClientRect(); 441 activeDragElement.mouseDeltaX = rect.left - touch.clientX; 442 activeDragElement.mouseDeltaY = rect.top - touch.clientY; 443 } 444 445 function touchMove(e: TouchEvent) { 446 if (!touchDragActive) return; 447 448 const touch = e.touches[0]; 449 if (!touch) return; 450 451 e.preventDefault(); 452 453 const result = getGridPosition(touch.clientX, touch.clientY); 454 if (!result || !activeDragElement.item) return; 455 456 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 457 458 // Reset all items to original positions first 459 for (const it of items) { 460 const origPos = activeDragElement.originalPositions.get(it.id); 461 if (origPos && it !== activeDragElement.item) { 462 if (isMobile) { 463 it.mobileX = origPos.mobileX; 464 it.mobileY = origPos.mobileY; 465 } else { 466 it.x = origPos.x; 467 it.y = origPos.y; 468 } 469 } 470 } 471 472 // Update dragged item position 473 if (isMobile) { 474 activeDragElement.item.mobileX = result.x; 475 activeDragElement.item.mobileY = result.y; 476 } else { 477 activeDragElement.item.x = result.x; 478 activeDragElement.item.y = result.y; 479 } 480 481 // Handle horizontal swap 482 if (result.swapWithId && draggedOrigPos) { 483 const swapTarget = items.find((it) => it.id === result.swapWithId); 484 if (swapTarget) { 485 if (isMobile) { 486 swapTarget.mobileX = draggedOrigPos.mobileX; 487 swapTarget.mobileY = draggedOrigPos.mobileY; 488 } else { 489 swapTarget.x = draggedOrigPos.x; 490 swapTarget.y = draggedOrigPos.y; 491 } 492 } 493 } 494 495 fixCollisions(items, activeDragElement.item, isMobile); 496 497 // Auto-scroll near edges 498 const scrollZone = 100; 499 const scrollSpeed = 10; 500 const viewportHeight = window.innerHeight; 501 502 if (touch.clientY < scrollZone) { 503 const intensity = 1 - touch.clientY / scrollZone; 504 window.scrollBy(0, -scrollSpeed * intensity); 505 } else if (touch.clientY > viewportHeight - scrollZone) { 506 const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 507 window.scrollBy(0, scrollSpeed * intensity); 508 } 509 } 510 511 function touchEnd() { 512 if (touchDragActive && activeDragElement.item) { 513 // Finalize position 514 fixCollisions(items, activeDragElement.item, isMobile); 515 onLayoutChanged(); 516 517 activeDragElement.x = -1; 518 activeDragElement.y = -1; 519 activeDragElement.element = null; 520 activeDragElement.item = null; 521 activeDragElement.lastTargetId = null; 522 activeDragElement.lastPlacement = null; 523 } 524 525 touchDragActive = false; 526 } 527 528 // Only register non-passive touchmove when actively dragging 529 $effect(() => { 530 const el = container; 531 if (!touchDragActive || !el) return; 532 533 el.addEventListener('touchmove', touchMove, { passive: false }); 534 return () => { 535 el.removeEventListener('touchmove', touchMove); 536 }; 537 }); 538 539 let linkValue = $state(''); 540 541 function addLink(url: string) { 542 let link = validateLink(url); 543 if (!link) { 544 toast.error('invalid link'); 545 return; 546 } 547 let item = createEmptyCard(data.page); 548 549 for (const cardDef of AllCardDefinitions.toSorted( 550 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 551 )) { 552 if (cardDef.onUrlHandler?.(link, item)) { 553 item.cardType = cardDef.type; 554 555 newItem.item = item; 556 saveNewItem(); 557 toast(cardDef.name + ' added!'); 558 break; 559 } 560 } 561 562 if (linkValue === url) { 563 linkValue = ''; 564 } 565 } 566 567 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 568 return new Promise((resolve) => { 569 const img = new Image(); 570 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 571 img.onerror = () => resolve({ width: 1, height: 1 }); 572 img.src = src; 573 }); 574 } 575 576 function getBestGridSize( 577 imageWidth: number, 578 imageHeight: number, 579 candidates: [number, number][] 580 ): [number, number] { 581 const imageRatio = imageWidth / imageHeight; 582 let best: [number, number] = candidates[0]; 583 let bestDiff = Infinity; 584 585 for (const candidate of candidates) { 586 const gridRatio = candidate[0] / candidate[1]; 587 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 588 if (diff < bestDiff) { 589 bestDiff = diff; 590 best = candidate; 591 } 592 } 593 594 return best; 595 } 596 597 const desktopSizeCandidates: [number, number][] = [ 598 [2, 2], 599 [2, 4], 600 [4, 2], 601 [4, 4], 602 [4, 6], 603 [6, 4] 604 ]; 605 const mobileSizeCandidates: [number, number][] = [ 606 [4, 4], 607 [4, 6], 608 [4, 8], 609 [6, 4], 610 [8, 4], 611 [8, 6] 612 ]; 613 614 async function processImageFile(file: File, gridX?: number, gridY?: number) { 615 const isGif = file.type === 'image/gif'; 616 617 // Don't compress GIFs to preserve animation 618 const objectUrl = URL.createObjectURL(file); 619 620 let item = createEmptyCard(data.page); 621 622 item.cardType = isGif ? 'gif' : 'image'; 623 item.cardData = { 624 image: { blob: file, objectUrl } 625 }; 626 627 // Size card based on image aspect ratio 628 const { width, height } = await getImageDimensions(objectUrl); 629 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 630 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 631 item.w = dw; 632 item.h = dh; 633 item.mobileW = mw; 634 item.mobileH = mh; 635 636 // If grid position is provided (image dropped on grid) 637 if (gridX !== undefined && gridY !== undefined) { 638 if (isMobile) { 639 item.mobileX = gridX; 640 item.mobileY = gridY; 641 // Derive desktop Y from mobile 642 item.x = Math.floor((COLUMNS - item.w) / 2); 643 item.x = Math.floor(item.x / 2) * 2; 644 item.y = Math.max(0, Math.round(gridY / 2)); 645 } else { 646 item.x = gridX; 647 item.y = gridY; 648 // Derive mobile Y from desktop 649 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 650 item.mobileX = Math.floor(item.mobileX / 2) * 2; 651 item.mobileY = Math.max(0, Math.round(gridY * 2)); 652 } 653 654 items = [...items, item]; 655 fixCollisions(items, item, isMobile); 656 fixCollisions(items, item, !isMobile); 657 } else { 658 const viewportCenter = getViewportCenterGridY(); 659 setPositionOfNewItem(item, items, viewportCenter); 660 items = [...items, item]; 661 fixCollisions(items, item, false, true); 662 fixCollisions(items, item, true, true); 663 compactItems(items, false); 664 compactItems(items, true); 665 } 666 667 onLayoutChanged(); 668 669 await tick(); 670 671 scrollToItem(item, isMobile, container); 672 } 673 674 function handleImageDragOver(event: DragEvent) { 675 const dt = event.dataTransfer; 676 if (!dt) return; 677 678 let hasImage = false; 679 if (dt.items) { 680 for (let i = 0; i < dt.items.length; i++) { 681 const item = dt.items[i]; 682 if (item && item.kind === 'file' && item.type.startsWith('image/')) { 683 hasImage = true; 684 break; 685 } 686 } 687 } else if (dt.files) { 688 for (let i = 0; i < dt.files.length; i++) { 689 const file = dt.files[i]; 690 if (file?.type.startsWith('image/')) { 691 hasImage = true; 692 break; 693 } 694 } 695 } 696 697 if (hasImage) { 698 event.preventDefault(); 699 event.stopPropagation(); 700 701 imageDragOver = true; 702 } 703 } 704 705 function handleImageDragLeave(event: DragEvent) { 706 event.preventDefault(); 707 event.stopPropagation(); 708 imageDragOver = false; 709 } 710 711 async function handleImageDrop(event: DragEvent) { 712 event.preventDefault(); 713 event.stopPropagation(); 714 const dropX = event.clientX; 715 const dropY = event.clientY; 716 imageDragOver = false; 717 718 if (!event.dataTransfer?.files?.length) return; 719 720 const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 721 f?.type.startsWith('image/') 722 ); 723 if (imageFiles.length === 0) return; 724 725 // Calculate starting grid position from drop coordinates 726 let gridX = 0; 727 let gridY = 0; 728 if (container) { 729 const rect = container.getBoundingClientRect(); 730 const currentMargin = isMobile ? mobileMargin : margin; 731 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 732 const cardW = isMobile ? 4 : 2; 733 734 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 735 gridX = Math.floor(gridX / 2) * 2; 736 737 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 738 if (isMobile) { 739 gridY = Math.floor(gridY / 2) * 2; 740 } 741 } 742 743 for (let i = 0; i < imageFiles.length; i++) { 744 // First image gets the drop position, rest use normal placement 745 if (i === 0) { 746 await processImageFile(imageFiles[i], gridX, gridY); 747 } else { 748 await processImageFile(imageFiles[i]); 749 } 750 } 751 } 752 753 async function handleImageInputChange(event: Event) { 754 const target = event.target as HTMLInputElement; 755 if (!target.files || target.files.length < 1) return; 756 757 const files = Array.from(target.files); 758 759 if (files.length === 1) { 760 // Single file: use default positioning 761 await processImageFile(files[0]); 762 } else { 763 // Multiple files: place in grid pattern starting from first available position 764 let gridX = 0; 765 let gridY = maxHeight; 766 const cardW = isMobile ? 4 : 2; 767 const cardH = isMobile ? 4 : 2; 768 769 for (const file of files) { 770 await processImageFile(file, gridX, gridY); 771 772 // Move to next cell position 773 gridX += cardW; 774 if (gridX + cardW > COLUMNS) { 775 gridX = 0; 776 gridY += cardH; 777 } 778 } 779 } 780 781 // Reset the input so the same file can be selected again 782 target.value = ''; 783 } 784 785 async function processVideoFile(file: File) { 786 const objectUrl = URL.createObjectURL(file); 787 788 let item = createEmptyCard(data.page); 789 790 item.cardType = 'video'; 791 item.cardData = { 792 blob: file, 793 objectUrl 794 }; 795 796 const viewportCenter = getViewportCenterGridY(); 797 setPositionOfNewItem(item, items, viewportCenter); 798 items = [...items, item]; 799 fixCollisions(items, item, false, true); 800 fixCollisions(items, item, true, true); 801 compactItems(items, false); 802 compactItems(items, true); 803 804 onLayoutChanged(); 805 806 await tick(); 807 808 scrollToItem(item, isMobile, container); 809 } 810 811 async function handleVideoInputChange(event: Event) { 812 const target = event.target as HTMLInputElement; 813 if (!target.files || target.files.length < 1) return; 814 815 const files = Array.from(target.files); 816 817 for (const file of files) { 818 await processVideoFile(file); 819 } 820 821 // Reset the input so the same file can be selected again 822 target.value = ''; 823 } 824 825 let showCardCommand = $state(false); 826</script> 827 828<svelte:body 829 onpaste={(event) => { 830 if (isTyping()) return; 831 832 const text = event.clipboardData?.getData('text/plain'); 833 const link = validateLink(text, false); 834 if (!link) return; 835 836 addLink(link); 837 }} 838/> 839 840<svelte:window 841 ondragover={handleImageDragOver} 842 ondragleave={handleImageDragLeave} 843 ondrop={handleImageDrop} 844/> 845 846<Head 847 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 848 title={getName(data)} 849 image={'/' + data.handle + '/og.png'} 850 accentColor={data.publication?.preferences?.accentColor} 851 baseColor={data.publication?.preferences?.baseColor} 852/> 853 854<Account {data} /> 855 856<Context {data}> 857 {#if !dev} 858 <div 859 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" 860 > 861 Editing on mobile is not supported yet. Please use a desktop browser. 862 </div> 863 {/if} 864 865 <CardCommand 866 bind:open={showCardCommand} 867 onselect={(cardDef: CardDefinition) => { 868 if (cardDef.type === 'image') { 869 const input = document.getElementById('image-input') as HTMLInputElement; 870 if (input) { 871 input.click(); 872 return; 873 } 874 } else { 875 newCard(cardDef.type); 876 } 877 }} 878 /> 879 880 <Controls bind:data /> 881 882 {#if showingMobileView} 883 <div 884 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 885 ></div> 886 {/if} 887 888 {#if newItem.modal && newItem.item} 889 <newItem.modal 890 oncreate={() => { 891 saveNewItem(); 892 }} 893 bind:item={newItem.item} 894 oncancel={() => { 895 newItem = {}; 896 }} 897 /> 898 {/if} 899 900 <SaveModal 901 bind:open={showSaveModal} 902 success={saveSuccess} 903 handle={data.handle} 904 page={data.page} 905 /> 906 907 <div 908 class={[ 909 '@container/wrapper relative w-full', 910 showingMobileView 911 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 912 : '' 913 ]} 914 > 915 {#if !getHideProfileSection(data)} 916 <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> 917 {/if} 918 919 <div 920 class={[ 921 'pointer-events-none relative mx-auto max-w-lg', 922 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 923 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 924 : '@5xl/wrapper:max-w-4xl' 925 ]} 926 > 927 <div class="pointer-events-none"></div> 928 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 929 <!-- svelte-ignore a11y_click_events_have_key_events --> 930 <div 931 bind:this={container} 932 onclick={(e) => { 933 // Deselect when tapping empty grid space 934 if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 935 selectedCardId = null; 936 } 937 }} 938 ontouchstart={touchStart} 939 ontouchend={touchEnd} 940 ondragover={(e) => { 941 e.preventDefault(); 942 943 const result = getDragXY(e); 944 if (!result) return; 945 946 activeDragElement.x = result.x; 947 activeDragElement.y = result.y; 948 949 if (activeDragElement.item) { 950 // Get dragged card's original position for swapping 951 const draggedOrigPos = activeDragElement.originalPositions.get( 952 activeDragElement.item.id 953 ); 954 955 // Reset all items to original positions first 956 for (const it of items) { 957 const origPos = activeDragElement.originalPositions.get(it.id); 958 if (origPos && it !== activeDragElement.item) { 959 if (isMobile) { 960 it.mobileX = origPos.mobileX; 961 it.mobileY = origPos.mobileY; 962 } else { 963 it.x = origPos.x; 964 it.y = origPos.y; 965 } 966 } 967 } 968 969 // Update dragged item position 970 if (isMobile) { 971 activeDragElement.item.mobileX = result.x; 972 activeDragElement.item.mobileY = result.y; 973 } else { 974 activeDragElement.item.x = result.x; 975 activeDragElement.item.y = result.y; 976 } 977 978 // Handle horizontal swap 979 if (result.swapWithId && draggedOrigPos) { 980 const swapTarget = items.find((it) => it.id === result.swapWithId); 981 if (swapTarget) { 982 // Move swap target to dragged card's original position 983 if (isMobile) { 984 swapTarget.mobileX = draggedOrigPos.mobileX; 985 swapTarget.mobileY = draggedOrigPos.mobileY; 986 } else { 987 swapTarget.x = draggedOrigPos.x; 988 swapTarget.y = draggedOrigPos.y; 989 } 990 } 991 } 992 993 // Now fix collisions (with compacting) 994 fixCollisions(items, activeDragElement.item, isMobile); 995 } 996 997 // Auto-scroll when dragging near top or bottom of viewport 998 const scrollZone = 100; 999 const scrollSpeed = 10; 1000 const viewportHeight = window.innerHeight; 1001 1002 if (e.clientY < scrollZone) { 1003 // Near top - scroll up 1004 const intensity = 1 - e.clientY / scrollZone; 1005 window.scrollBy(0, -scrollSpeed * intensity); 1006 } else if (e.clientY > viewportHeight - scrollZone) { 1007 // Near bottom - scroll down 1008 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 1009 window.scrollBy(0, scrollSpeed * intensity); 1010 } 1011 }} 1012 ondragend={async (e) => { 1013 e.preventDefault(); 1014 const cell = getDragXY(e); 1015 if (!cell) return; 1016 1017 if (activeDragElement.item) { 1018 if (isMobile) { 1019 activeDragElement.item.mobileX = cell.x; 1020 activeDragElement.item.mobileY = cell.y; 1021 } else { 1022 activeDragElement.item.x = cell.x; 1023 activeDragElement.item.y = cell.y; 1024 } 1025 1026 // Fix collisions and compact items after drag ends 1027 fixCollisions(items, activeDragElement.item, isMobile); 1028 onLayoutChanged(); 1029 } 1030 activeDragElement.x = -1; 1031 activeDragElement.y = -1; 1032 activeDragElement.element = null; 1033 activeDragElement.item = null; 1034 activeDragElement.lastTargetId = null; 1035 activeDragElement.lastPlacement = null; 1036 return true; 1037 }} 1038 class={[ 1039 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 1040 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 1041 ]} 1042 > 1043 {#each items as item, i (item.id)} 1044 <!-- {#if item !== activeDragElement.item} --> 1045 <BaseEditingCard 1046 bind:item={items[i]} 1047 ondelete={() => { 1048 items = items.filter((it) => it !== item); 1049 compactItems(items, false); 1050 compactItems(items, true); 1051 onLayoutChanged(); 1052 }} 1053 onsetsize={(newW: number, newH: number) => { 1054 if (isMobile) { 1055 item.mobileW = newW; 1056 item.mobileH = newH; 1057 } else { 1058 item.w = newW; 1059 item.h = newH; 1060 } 1061 1062 fixCollisions(items, item, isMobile); 1063 onLayoutChanged(); 1064 }} 1065 ondragstart={(e: DragEvent) => { 1066 const target = e.currentTarget as HTMLDivElement; 1067 activeDragElement.element = target; 1068 activeDragElement.w = item.w; 1069 activeDragElement.h = item.h; 1070 activeDragElement.item = item; 1071 1072 // Store original positions of all items 1073 activeDragElement.originalPositions = new Map(); 1074 for (const it of items) { 1075 activeDragElement.originalPositions.set(it.id, { 1076 x: it.x, 1077 y: it.y, 1078 mobileX: it.mobileX, 1079 mobileY: it.mobileY 1080 }); 1081 } 1082 1083 const rect = target.getBoundingClientRect(); 1084 activeDragElement.mouseDeltaX = rect.left - e.clientX; 1085 activeDragElement.mouseDeltaY = rect.top - e.clientY; 1086 }} 1087 > 1088 <EditingCard bind:item={items[i]} /> 1089 </BaseEditingCard> 1090 <!-- {/if} --> 1091 {/each} 1092 1093 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 1094 </div> 1095 </div> 1096 </div> 1097 1098 <EditBar 1099 {data} 1100 bind:linkValue 1101 bind:isSaving 1102 bind:showingMobileView 1103 {hasUnsavedChanges} 1104 {newCard} 1105 {addLink} 1106 {save} 1107 {handleImageInputChange} 1108 {handleVideoInputChange} 1109 showCardCommand={() => { 1110 showCardCommand = true; 1111 }} 1112 {selectedCard} 1113 {isMobile} 1114 {isCoarse} 1115 ondeselect={() => { 1116 selectedCardId = null; 1117 }} 1118 ondelete={() => { 1119 if (selectedCard) { 1120 items = items.filter((it) => it.id !== selectedCardId); 1121 compactItems(items, false); 1122 compactItems(items, true); 1123 onLayoutChanged(); 1124 selectedCardId = null; 1125 } 1126 }} 1127 onsetsize={(w: number, h: number) => { 1128 if (selectedCard) { 1129 if (isMobile) { 1130 selectedCard.mobileW = w; 1131 selectedCard.mobileH = h; 1132 } else { 1133 selectedCard.w = w; 1134 selectedCard.h = h; 1135 } 1136 fixCollisions(items, selectedCard, isMobile); 1137 onLayoutChanged(); 1138 } 1139 }} 1140 /> 1141 1142 <Toaster /> 1143 1144 <FloatingEditButton {data} /> 1145 1146 {#if dev} 1147 <div class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs"> 1148 editedOn: {editedOn} 1149 </div> 1150 {/if} 1151</Context>