your personal website on atproto - mirror blento.app
at card-command-bar-v2 1160 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, specificCardDef?: CardDefinition) { 542 let link = validateLink(url); 543 if (!link) { 544 toast.error('invalid link'); 545 return; 546 } 547 let item = createEmptyCard(data.page); 548 549 if (specificCardDef?.onUrlHandler?.(link, item)) { 550 item.cardType = specificCardDef.type; 551 newItem.item = item; 552 saveNewItem(); 553 toast(specificCardDef.name + ' added!'); 554 return; 555 } 556 557 for (const cardDef of AllCardDefinitions.toSorted( 558 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 559 )) { 560 if (cardDef.onUrlHandler?.(link, item)) { 561 item.cardType = cardDef.type; 562 563 newItem.item = item; 564 saveNewItem(); 565 toast(cardDef.name + ' added!'); 566 break; 567 } 568 } 569 } 570 571 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 572 return new Promise((resolve) => { 573 const img = new Image(); 574 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 575 img.onerror = () => resolve({ width: 1, height: 1 }); 576 img.src = src; 577 }); 578 } 579 580 function getBestGridSize( 581 imageWidth: number, 582 imageHeight: number, 583 candidates: [number, number][] 584 ): [number, number] { 585 const imageRatio = imageWidth / imageHeight; 586 let best: [number, number] = candidates[0]; 587 let bestDiff = Infinity; 588 589 for (const candidate of candidates) { 590 const gridRatio = candidate[0] / candidate[1]; 591 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 592 if (diff < bestDiff) { 593 bestDiff = diff; 594 best = candidate; 595 } 596 } 597 598 return best; 599 } 600 601 const desktopSizeCandidates: [number, number][] = [ 602 [2, 2], 603 [2, 4], 604 [4, 2], 605 [4, 4], 606 [4, 6], 607 [6, 4] 608 ]; 609 const mobileSizeCandidates: [number, number][] = [ 610 [4, 4], 611 [4, 6], 612 [4, 8], 613 [6, 4], 614 [8, 4], 615 [8, 6] 616 ]; 617 618 async function processImageFile(file: File, gridX?: number, gridY?: number) { 619 const isGif = file.type === 'image/gif'; 620 621 // Don't compress GIFs to preserve animation 622 const objectUrl = URL.createObjectURL(file); 623 624 let item = createEmptyCard(data.page); 625 626 item.cardType = isGif ? 'gif' : 'image'; 627 item.cardData = { 628 image: { blob: file, objectUrl } 629 }; 630 631 // Size card based on image aspect ratio 632 const { width, height } = await getImageDimensions(objectUrl); 633 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 634 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 635 item.w = dw; 636 item.h = dh; 637 item.mobileW = mw; 638 item.mobileH = mh; 639 640 // If grid position is provided (image dropped on grid) 641 if (gridX !== undefined && gridY !== undefined) { 642 if (isMobile) { 643 item.mobileX = gridX; 644 item.mobileY = gridY; 645 // Derive desktop Y from mobile 646 item.x = Math.floor((COLUMNS - item.w) / 2); 647 item.x = Math.floor(item.x / 2) * 2; 648 item.y = Math.max(0, Math.round(gridY / 2)); 649 } else { 650 item.x = gridX; 651 item.y = gridY; 652 // Derive mobile Y from desktop 653 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 654 item.mobileX = Math.floor(item.mobileX / 2) * 2; 655 item.mobileY = Math.max(0, Math.round(gridY * 2)); 656 } 657 658 items = [...items, item]; 659 fixCollisions(items, item, isMobile); 660 fixCollisions(items, item, !isMobile); 661 } else { 662 const viewportCenter = getViewportCenterGridY(); 663 setPositionOfNewItem(item, items, viewportCenter); 664 items = [...items, item]; 665 fixCollisions(items, item, false, true); 666 fixCollisions(items, item, true, true); 667 compactItems(items, false); 668 compactItems(items, true); 669 } 670 671 onLayoutChanged(); 672 673 await tick(); 674 675 scrollToItem(item, isMobile, container); 676 } 677 678 function handleImageDragOver(event: DragEvent) { 679 const dt = event.dataTransfer; 680 if (!dt) return; 681 682 let hasImage = false; 683 if (dt.items) { 684 for (let i = 0; i < dt.items.length; i++) { 685 const item = dt.items[i]; 686 if (item && item.kind === 'file' && item.type.startsWith('image/')) { 687 hasImage = true; 688 break; 689 } 690 } 691 } else if (dt.files) { 692 for (let i = 0; i < dt.files.length; i++) { 693 const file = dt.files[i]; 694 if (file?.type.startsWith('image/')) { 695 hasImage = true; 696 break; 697 } 698 } 699 } 700 701 if (hasImage) { 702 event.preventDefault(); 703 event.stopPropagation(); 704 705 imageDragOver = true; 706 } 707 } 708 709 function handleImageDragLeave(event: DragEvent) { 710 event.preventDefault(); 711 event.stopPropagation(); 712 imageDragOver = false; 713 } 714 715 async function handleImageDrop(event: DragEvent) { 716 event.preventDefault(); 717 event.stopPropagation(); 718 const dropX = event.clientX; 719 const dropY = event.clientY; 720 imageDragOver = false; 721 722 if (!event.dataTransfer?.files?.length) return; 723 724 const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 725 f?.type.startsWith('image/') 726 ); 727 if (imageFiles.length === 0) return; 728 729 // Calculate starting grid position from drop coordinates 730 let gridX = 0; 731 let gridY = 0; 732 if (container) { 733 const rect = container.getBoundingClientRect(); 734 const currentMargin = isMobile ? mobileMargin : margin; 735 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 736 const cardW = isMobile ? 4 : 2; 737 738 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 739 gridX = Math.floor(gridX / 2) * 2; 740 741 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 742 if (isMobile) { 743 gridY = Math.floor(gridY / 2) * 2; 744 } 745 } 746 747 for (let i = 0; i < imageFiles.length; i++) { 748 // First image gets the drop position, rest use normal placement 749 if (i === 0) { 750 await processImageFile(imageFiles[i], gridX, gridY); 751 } else { 752 await processImageFile(imageFiles[i]); 753 } 754 } 755 } 756 757 async function handleImageInputChange(event: Event) { 758 const target = event.target as HTMLInputElement; 759 if (!target.files || target.files.length < 1) return; 760 761 const files = Array.from(target.files); 762 763 if (files.length === 1) { 764 // Single file: use default positioning 765 await processImageFile(files[0]); 766 } else { 767 // Multiple files: place in grid pattern starting from first available position 768 let gridX = 0; 769 let gridY = maxHeight; 770 const cardW = isMobile ? 4 : 2; 771 const cardH = isMobile ? 4 : 2; 772 773 for (const file of files) { 774 await processImageFile(file, gridX, gridY); 775 776 // Move to next cell position 777 gridX += cardW; 778 if (gridX + cardW > COLUMNS) { 779 gridX = 0; 780 gridY += cardH; 781 } 782 } 783 } 784 785 // Reset the input so the same file can be selected again 786 target.value = ''; 787 } 788 789 async function processVideoFile(file: File) { 790 const objectUrl = URL.createObjectURL(file); 791 792 let item = createEmptyCard(data.page); 793 794 item.cardType = 'video'; 795 item.cardData = { 796 blob: file, 797 objectUrl 798 }; 799 800 const viewportCenter = getViewportCenterGridY(); 801 setPositionOfNewItem(item, items, viewportCenter); 802 items = [...items, item]; 803 fixCollisions(items, item, false, true); 804 fixCollisions(items, item, true, true); 805 compactItems(items, false); 806 compactItems(items, true); 807 808 onLayoutChanged(); 809 810 await tick(); 811 812 scrollToItem(item, isMobile, container); 813 } 814 815 async function handleVideoInputChange(event: Event) { 816 const target = event.target as HTMLInputElement; 817 if (!target.files || target.files.length < 1) return; 818 819 const files = Array.from(target.files); 820 821 for (const file of files) { 822 await processVideoFile(file); 823 } 824 825 // Reset the input so the same file can be selected again 826 target.value = ''; 827 } 828 829 let showCardCommand = $state(false); 830</script> 831 832<svelte:body 833 onpaste={(event) => { 834 if (isTyping()) return; 835 836 const text = event.clipboardData?.getData('text/plain'); 837 const link = validateLink(text, false); 838 if (!link) return; 839 840 addLink(link); 841 }} 842/> 843 844<svelte:window 845 ondragover={handleImageDragOver} 846 ondragleave={handleImageDragLeave} 847 ondrop={handleImageDrop} 848/> 849 850<Head 851 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 852 title={getName(data)} 853 image={'/' + data.handle + '/og.png'} 854 accentColor={data.publication?.preferences?.accentColor} 855 baseColor={data.publication?.preferences?.baseColor} 856/> 857 858<Account {data} /> 859 860<Context {data}> 861 {#if !dev} 862 <div 863 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" 864 > 865 Editing on mobile is not supported yet. Please use a desktop browser. 866 </div> 867 {/if} 868 869 <CardCommand 870 bind:open={showCardCommand} 871 onselect={(cardDef: CardDefinition) => { 872 if (cardDef.type === 'image') { 873 const input = document.getElementById('image-input') as HTMLInputElement; 874 if (input) { 875 input.click(); 876 return; 877 } 878 } else if (cardDef.type === 'video') { 879 const input = document.getElementById('video-input') as HTMLInputElement; 880 if (input) { 881 input.click(); 882 return; 883 } 884 } else { 885 newCard(cardDef.type); 886 } 887 }} 888 onlink={(url, cardDef) => { 889 addLink(url, cardDef); 890 }} 891 /> 892 893 <Controls bind:data /> 894 895 {#if showingMobileView} 896 <div 897 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 898 ></div> 899 {/if} 900 901 {#if newItem.modal && newItem.item} 902 <newItem.modal 903 oncreate={() => { 904 saveNewItem(); 905 }} 906 bind:item={newItem.item} 907 oncancel={() => { 908 newItem = {}; 909 }} 910 /> 911 {/if} 912 913 <SaveModal 914 bind:open={showSaveModal} 915 success={saveSuccess} 916 handle={data.handle} 917 page={data.page} 918 /> 919 920 <div 921 class={[ 922 '@container/wrapper relative w-full', 923 showingMobileView 924 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 925 : '' 926 ]} 927 > 928 {#if !getHideProfileSection(data)} 929 <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> 930 {/if} 931 932 <div 933 class={[ 934 'pointer-events-none relative mx-auto max-w-lg', 935 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 936 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 937 : '@5xl/wrapper:max-w-4xl' 938 ]} 939 > 940 <div class="pointer-events-none"></div> 941 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 942 <!-- svelte-ignore a11y_click_events_have_key_events --> 943 <div 944 bind:this={container} 945 onclick={(e) => { 946 // Deselect when tapping empty grid space 947 if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 948 selectedCardId = null; 949 } 950 }} 951 ontouchstart={touchStart} 952 ontouchend={touchEnd} 953 ondragover={(e) => { 954 e.preventDefault(); 955 956 const result = getDragXY(e); 957 if (!result) return; 958 959 activeDragElement.x = result.x; 960 activeDragElement.y = result.y; 961 962 if (activeDragElement.item) { 963 // Get dragged card's original position for swapping 964 const draggedOrigPos = activeDragElement.originalPositions.get( 965 activeDragElement.item.id 966 ); 967 968 // Reset all items to original positions first 969 for (const it of items) { 970 const origPos = activeDragElement.originalPositions.get(it.id); 971 if (origPos && it !== activeDragElement.item) { 972 if (isMobile) { 973 it.mobileX = origPos.mobileX; 974 it.mobileY = origPos.mobileY; 975 } else { 976 it.x = origPos.x; 977 it.y = origPos.y; 978 } 979 } 980 } 981 982 // Update dragged item position 983 if (isMobile) { 984 activeDragElement.item.mobileX = result.x; 985 activeDragElement.item.mobileY = result.y; 986 } else { 987 activeDragElement.item.x = result.x; 988 activeDragElement.item.y = result.y; 989 } 990 991 // Handle horizontal swap 992 if (result.swapWithId && draggedOrigPos) { 993 const swapTarget = items.find((it) => it.id === result.swapWithId); 994 if (swapTarget) { 995 // Move swap target to dragged card's original position 996 if (isMobile) { 997 swapTarget.mobileX = draggedOrigPos.mobileX; 998 swapTarget.mobileY = draggedOrigPos.mobileY; 999 } else { 1000 swapTarget.x = draggedOrigPos.x; 1001 swapTarget.y = draggedOrigPos.y; 1002 } 1003 } 1004 } 1005 1006 // Now fix collisions (with compacting) 1007 fixCollisions(items, activeDragElement.item, isMobile); 1008 } 1009 1010 // Auto-scroll when dragging near top or bottom of viewport 1011 const scrollZone = 100; 1012 const scrollSpeed = 10; 1013 const viewportHeight = window.innerHeight; 1014 1015 if (e.clientY < scrollZone) { 1016 // Near top - scroll up 1017 const intensity = 1 - e.clientY / scrollZone; 1018 window.scrollBy(0, -scrollSpeed * intensity); 1019 } else if (e.clientY > viewportHeight - scrollZone) { 1020 // Near bottom - scroll down 1021 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 1022 window.scrollBy(0, scrollSpeed * intensity); 1023 } 1024 }} 1025 ondragend={async (e) => { 1026 e.preventDefault(); 1027 // safari fix 1028 activeDragElement.x = -1; 1029 activeDragElement.y = -1; 1030 activeDragElement.element = null; 1031 activeDragElement.item = null; 1032 activeDragElement.lastTargetId = null; 1033 activeDragElement.lastPlacement = null; 1034 return true; 1035 }} 1036 class={[ 1037 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 1038 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 1039 ]} 1040 > 1041 {#each items as item, i (item.id)} 1042 <!-- {#if item !== activeDragElement.item} --> 1043 <BaseEditingCard 1044 bind:item={items[i]} 1045 ondelete={() => { 1046 items = items.filter((it) => it !== item); 1047 compactItems(items, false); 1048 compactItems(items, true); 1049 onLayoutChanged(); 1050 }} 1051 onsetsize={(newW: number, newH: number) => { 1052 if (isMobile) { 1053 item.mobileW = newW; 1054 item.mobileH = newH; 1055 } else { 1056 item.w = newW; 1057 item.h = newH; 1058 } 1059 1060 fixCollisions(items, item, isMobile); 1061 onLayoutChanged(); 1062 }} 1063 ondragstart={(e: DragEvent) => { 1064 const target = e.currentTarget as HTMLDivElement; 1065 activeDragElement.element = target; 1066 activeDragElement.w = item.w; 1067 activeDragElement.h = item.h; 1068 activeDragElement.item = item; 1069 // fix for div shadow during drag and drop 1070 const transparent = document.createElement('div'); 1071 transparent.style.position = 'fixed'; 1072 transparent.style.top = '-1000px'; 1073 transparent.style.width = '1px'; 1074 transparent.style.height = '1px'; 1075 document.body.appendChild(transparent); 1076 e.dataTransfer?.setDragImage(transparent, 0, 0); 1077 requestAnimationFrame(() => transparent.remove()); 1078 1079 // Store original positions of all items 1080 activeDragElement.originalPositions = new Map(); 1081 for (const it of items) { 1082 activeDragElement.originalPositions.set(it.id, { 1083 x: it.x, 1084 y: it.y, 1085 mobileX: it.mobileX, 1086 mobileY: it.mobileY 1087 }); 1088 } 1089 1090 const rect = target.getBoundingClientRect(); 1091 activeDragElement.mouseDeltaX = rect.left - e.clientX; 1092 activeDragElement.mouseDeltaY = rect.top - e.clientY; 1093 }} 1094 > 1095 <EditingCard bind:item={items[i]} /> 1096 </BaseEditingCard> 1097 <!-- {/if} --> 1098 {/each} 1099 1100 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 1101 </div> 1102 </div> 1103 </div> 1104 1105 <EditBar 1106 {data} 1107 bind:linkValue 1108 bind:isSaving 1109 bind:showingMobileView 1110 {hasUnsavedChanges} 1111 {newCard} 1112 {addLink} 1113 {save} 1114 {handleImageInputChange} 1115 {handleVideoInputChange} 1116 showCardCommand={() => { 1117 showCardCommand = true; 1118 }} 1119 {selectedCard} 1120 {isMobile} 1121 {isCoarse} 1122 ondeselect={() => { 1123 selectedCardId = null; 1124 }} 1125 ondelete={() => { 1126 if (selectedCard) { 1127 items = items.filter((it) => it.id !== selectedCardId); 1128 compactItems(items, false); 1129 compactItems(items, true); 1130 onLayoutChanged(); 1131 selectedCardId = null; 1132 } 1133 }} 1134 onsetsize={(w: number, h: number) => { 1135 if (selectedCard) { 1136 if (isMobile) { 1137 selectedCard.mobileW = w; 1138 selectedCard.mobileH = h; 1139 } else { 1140 selectedCard.w = w; 1141 selectedCard.h = h; 1142 } 1143 fixCollisions(items, selectedCard, isMobile); 1144 onLayoutChanged(); 1145 } 1146 }} 1147 /> 1148 1149 <Toaster /> 1150 1151 <FloatingEditButton {data} /> 1152 1153 {#if dev} 1154 <div 1155 class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs" 1156 > 1157 editedOn: {editedOn} 1158 </div> 1159 {/if} 1160</Context>