your personal website on atproto - mirror blento.app
at fixes 1534 lines 44 kB view raw
1<script lang="ts"> 2 import { Button, Modal, 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 fixAllCollisions, 11 fixCollisions, 12 getHideProfileSection, 13 getProfilePosition, 14 getName, 15 isTyping, 16 savePage, 17 scrollToItem, 18 setPositionOfNewItem, 19 validateLink, 20 getImage 21 } from '../helper'; 22 import EditableProfile from './EditableProfile.svelte'; 23 import type { Item, WebsiteData } from '../types'; 24 import { innerWidth } from 'svelte/reactivity/window'; 25 import EditingCard from '../cards/_base/Card/EditingCard.svelte'; 26 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 27 import { tick, type Component } from 'svelte'; 28 import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 29 import { dev } from '$app/environment'; 30 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 31 import BaseEditingCard from '../cards/_base/BaseCard/BaseEditingCard.svelte'; 32 import Context from './Context.svelte'; 33 import Head from './Head.svelte'; 34 import Account from './Account.svelte'; 35 import { SelectThemePopover } from '$lib/components/select-theme'; 36 import EditBar from './EditBar.svelte'; 37 import SaveModal from './SaveModal.svelte'; 38 import FloatingEditButton from './FloatingEditButton.svelte'; 39 import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 40 import * as TID from '@atcute/tid'; 41 import { launchConfetti } from '@foxui/visual'; 42 import Controls from './Controls.svelte'; 43 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 44 import { shouldMirror, mirrorLayout } from './layout-mirror'; 45 import { SvelteMap } from 'svelte/reactivity'; 46 47 let { 48 data 49 }: { 50 data: WebsiteData; 51 } = $props(); 52 53 // Check if floating login button will be visible (to hide MadeWithBlento) 54 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 55 56 function updateTheme(newAccent: string, newBase: string) { 57 data.publication.preferences ??= {}; 58 data.publication.preferences.accentColor = newAccent; 59 data.publication.preferences.baseColor = newBase; 60 hasUnsavedChanges = true; 61 data = { ...data }; 62 } 63 64 let imageDragOver = $state(false); 65 66 // svelte-ignore state_referenced_locally 67 let items: Item[] = $state(data.cards); 68 69 // svelte-ignore state_referenced_locally 70 let publication = $state(JSON.stringify(data.publication)); 71 72 // svelte-ignore state_referenced_locally 73 let savedItemsSnapshot = JSON.stringify(data.cards); 74 75 let hasUnsavedChanges = $state(false); 76 77 // Detect card content and publication changes (e.g. sidebar edits) 78 // The guard ensures JSON.stringify only runs while no changes are detected yet. 79 // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations 80 // but the early return makes it effectively free. 81 $effect(() => { 82 if (hasUnsavedChanges) return; 83 if ( 84 JSON.stringify(items) !== savedItemsSnapshot || 85 JSON.stringify(data.publication) !== publication 86 ) { 87 hasUnsavedChanges = true; 88 } 89 }); 90 91 // Warn user before closing tab if there are unsaved changes 92 $effect(() => { 93 function handleBeforeUnload(e: BeforeUnloadEvent) { 94 if (hasUnsavedChanges) { 95 e.preventDefault(); 96 return ''; 97 } 98 } 99 100 window.addEventListener('beforeunload', handleBeforeUnload); 101 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 102 }); 103 104 let container: HTMLDivElement | undefined = $state(); 105 106 let activeDragElement: { 107 element: HTMLDivElement | null; 108 item: Item | null; 109 w: number; 110 h: number; 111 x: number; 112 y: number; 113 mouseDeltaX: number; 114 mouseDeltaY: number; 115 // For hysteresis - track last decision to prevent flickering 116 lastTargetId: string | null; 117 lastPlacement: 'above' | 'below' | null; 118 // Store original positions to reset from during drag 119 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 120 } = $state({ 121 element: null, 122 item: null, 123 w: 0, 124 h: 0, 125 x: -1, 126 y: -1, 127 mouseDeltaX: 0, 128 mouseDeltaY: 0, 129 lastTargetId: null, 130 lastPlacement: null, 131 originalPositions: new Map() 132 }); 133 134 let showingMobileView = $state(false); 135 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 136 let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024); 137 138 setIsMobile(() => isMobile); 139 140 // svelte-ignore state_referenced_locally 141 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 142 143 function onLayoutChanged() { 144 hasUnsavedChanges = true; 145 // Set the bit for the current layout: desktop=1, mobile=2 146 editedOn = editedOn | (isMobile ? 2 : 1); 147 if (shouldMirror(editedOn)) { 148 mirrorLayout(items, isMobile); 149 } 150 } 151 152 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 153 setIsCoarse(() => isCoarse); 154 155 let selectedCardId: string | null = $state(null); 156 let selectedCard = $derived( 157 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 158 ); 159 160 setSelectedCardId(() => selectedCardId); 161 setSelectCard((id: string | null) => { 162 selectedCardId = id; 163 }); 164 165 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 166 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 167 168 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 169 170 function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 171 if (!container) return undefined; 172 const rect = container.getBoundingClientRect(); 173 const currentMargin = isMobile ? mobileMargin : margin; 174 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 175 const viewportCenterY = window.innerHeight / 2; 176 const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 177 return { gridY, isMobile }; 178 } 179 180 function newCard(type: string = 'link', cardData?: any) { 181 selectedCardId = null; 182 183 // close sidebar if open 184 const popover = document.getElementById('mobile-menu'); 185 if (popover) { 186 popover.hidePopover(); 187 } 188 189 let item = createEmptyCard(data.page); 190 item.cardType = type; 191 192 item.cardData = cardData ?? {}; 193 194 const cardDef = CardDefinitionsByType[type]; 195 cardDef?.createNew?.(item); 196 197 newItem.item = item; 198 199 if (cardDef?.creationModalComponent) { 200 newItem.modal = cardDef.creationModalComponent; 201 } else { 202 saveNewItem(); 203 } 204 } 205 206 function cleanupDialogArtifacts() { 207 // bits-ui's body scroll lock and portal may not clean up fully when the 208 // modal is unmounted instead of closed via the open prop. 209 const restore = () => { 210 document.body.style.removeProperty('overflow'); 211 document.body.style.removeProperty('pointer-events'); 212 document.body.style.removeProperty('padding-right'); 213 document.body.style.removeProperty('margin-right'); 214 // Remove any orphaned dialog overlay/content elements left by the portal 215 for (const el of document.querySelectorAll('[data-dialog-overlay], [data-dialog-content]')) { 216 el.remove(); 217 } 218 }; 219 // Run immediately and again after bits-ui's 24ms scheduled cleanup 220 restore(); 221 setTimeout(restore, 50); 222 } 223 224 async function saveNewItem() { 225 if (!newItem.item) return; 226 const item = newItem.item; 227 228 const viewportCenter = getViewportCenterGridY(); 229 setPositionOfNewItem(item, items, viewportCenter); 230 231 items = [...items, item]; 232 233 // Push overlapping items down, then compact to fill gaps 234 fixCollisions(items, item, false, true); 235 fixCollisions(items, item, true, true); 236 compactItems(items, false); 237 compactItems(items, true); 238 239 onLayoutChanged(); 240 241 newItem = {}; 242 243 await tick(); 244 cleanupDialogArtifacts(); 245 246 scrollToItem(item, isMobile, container); 247 } 248 249 let isSaving = $state(false); 250 let showSaveModal = $state(false); 251 let saveSuccess = $state(false); 252 253 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 254 255 async function save() { 256 isSaving = true; 257 saveSuccess = false; 258 showSaveModal = true; 259 260 try { 261 // Upload profile icon if changed 262 if (data.publication?.icon) { 263 await checkAndUploadImage(data.publication, 'icon'); 264 } 265 266 // Persist layout editing state 267 data.publication.preferences ??= {}; 268 data.publication.preferences.editedOn = editedOn; 269 270 await savePage(data, items, publication); 271 272 publication = JSON.stringify(data.publication); 273 274 savedItemsSnapshot = JSON.stringify(items); 275 hasUnsavedChanges = false; 276 277 saveSuccess = true; 278 279 launchConfetti(); 280 281 // Refresh cached data 282 await fetch('/' + data.handle + '/api/refresh'); 283 } catch (error) { 284 console.error(error); 285 showSaveModal = false; 286 toast.error('Error saving page!'); 287 } finally { 288 isSaving = false; 289 } 290 } 291 292 function addAllCardTypes() { 293 const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; 294 const grouped = new SvelteMap<string, CardDefinition[]>(); 295 296 for (const def of AllCardDefinitions) { 297 if (!def.name) continue; 298 const group = def.groups?.[0] ?? 'Other'; 299 if (!grouped.has(group)) grouped.set(group, []); 300 grouped.get(group)!.push(def); 301 } 302 303 // Sort groups by predefined order, unknowns at end 304 const sortedGroups = [...grouped.keys()].sort((a, b) => { 305 const ai = groupOrder.indexOf(a); 306 const bi = groupOrder.indexOf(b); 307 return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); 308 }); 309 310 // Sample data for cards that would otherwise render empty 311 const sampleData: Record<string, Record<string, unknown>> = { 312 text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' }, 313 link: { 314 href: 'https://bsky.app', 315 title: 'Bluesky', 316 domain: 'bsky.app', 317 description: 'Social networking that gives you choice', 318 hasFetched: true 319 }, 320 image: { 321 image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600', 322 alt: 'Mountain landscape' 323 }, 324 button: { text: 'Visit Bluesky', href: 'https://bsky.app' }, 325 bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' }, 326 blueskyPost: { 327 uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y', 328 href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y' 329 }, 330 blueskyProfile: { 331 handle: 'bsky.app', 332 displayName: 'Bluesky', 333 avatar: 334 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg' 335 }, 336 blueskyMedia: {}, 337 latestPost: {}, 338 youtubeVideo: { 339 youtubeId: 'dQw4w9WgXcQ', 340 poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 341 href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 342 showInline: true 343 }, 344 'spotify-list-embed': { 345 spotifyType: 'album', 346 spotifyId: '4aawyAB9vmqN3uQ7FjRGTy', 347 href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy' 348 }, 349 latestLivestream: {}, 350 livestreamEmbed: { 351 href: 'https://stream.place/', 352 embed: 'https://stream.place/embed/' 353 }, 354 mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' }, 355 gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' }, 356 event: { 357 uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q' 358 }, 359 guestbook: { label: 'Guestbook' }, 360 githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' }, 361 photoGallery: { 362 galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w' 363 }, 364 atprotocollections: {}, 365 publicationList: {}, 366 recentPopfeedReviews: {}, 367 recentTealFMPlays: {}, 368 statusphere: { emoji: '✨' }, 369 vcard: {}, 370 'fluid-text': { text: 'Hello World' }, 371 draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true }, 372 clock: {}, 373 countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() }, 374 timer: {}, 375 'dino-game': {}, 376 tetris: {}, 377 updatedBlentos: {} 378 }; 379 380 // Labels for cards that support canHaveLabel 381 const sampleLabels: Record<string, string> = { 382 image: 'Mountain Landscape', 383 mapLocation: 'Eiffel Tower', 384 gif: 'Cat Typing', 385 bigsocial: 'Bluesky', 386 guestbook: 'Guestbook', 387 statusphere: 'My Status', 388 recentPopfeedReviews: 'My Reviews', 389 recentTealFMPlays: 'Recently Played', 390 clock: 'Local Time', 391 countdown: 'Launch Day', 392 timer: 'Timer', 393 'dino-game': 'Dino Game', 394 tetris: 'Tetris', 395 blueskyMedia: 'Bluesky Media' 396 }; 397 398 const newItems: Item[] = []; 399 let cursorY = 0; 400 let mobileCursorY = 0; 401 402 for (const group of sortedGroups) { 403 const defs = grouped.get(group)!; 404 405 // Add a section heading for the group 406 const heading = createEmptyCard(data.page); 407 heading.cardType = 'section'; 408 heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 }; 409 heading.w = COLUMNS; 410 heading.h = 1; 411 heading.x = 0; 412 heading.y = cursorY; 413 heading.mobileW = COLUMNS; 414 heading.mobileH = 2; 415 heading.mobileX = 0; 416 heading.mobileY = mobileCursorY; 417 newItems.push(heading); 418 cursorY += 1; 419 mobileCursorY += 2; 420 421 // Place cards in rows 422 let rowX = 0; 423 let rowMaxH = 0; 424 let mobileRowX = 0; 425 let mobileRowMaxH = 0; 426 427 for (const def of defs) { 428 if (def.type === 'section' || def.type === 'embed') continue; 429 430 const item = createEmptyCard(data.page); 431 item.cardType = def.type; 432 item.cardData = {}; 433 def.createNew?.(item); 434 435 // Merge in sample data (without overwriting createNew defaults) 436 const extra = sampleData[def.type]; 437 if (extra) { 438 item.cardData = { ...item.cardData, ...extra }; 439 } 440 441 // Set item-level color for cards that need it 442 if (def.type === 'button') { 443 item.color = 'transparent'; 444 } 445 446 // Add label if card supports it 447 const label = sampleLabels[def.type]; 448 if (label && def.canHaveLabel) { 449 item.cardData.label = label; 450 } 451 452 // Desktop layout 453 if (rowX + item.w > COLUMNS) { 454 cursorY += rowMaxH; 455 rowX = 0; 456 rowMaxH = 0; 457 } 458 item.x = rowX; 459 item.y = cursorY; 460 rowX += item.w; 461 rowMaxH = Math.max(rowMaxH, item.h); 462 463 // Mobile layout 464 if (mobileRowX + item.mobileW > COLUMNS) { 465 mobileCursorY += mobileRowMaxH; 466 mobileRowX = 0; 467 mobileRowMaxH = 0; 468 } 469 item.mobileX = mobileRowX; 470 item.mobileY = mobileCursorY; 471 mobileRowX += item.mobileW; 472 mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH); 473 474 newItems.push(item); 475 } 476 477 // Move cursor past last row 478 cursorY += rowMaxH; 479 mobileCursorY += mobileRowMaxH; 480 } 481 482 items = newItems; 483 onLayoutChanged(); 484 } 485 486 let copyInput = $state(''); 487 let isCopying = $state(false); 488 489 async function copyPageFrom() { 490 const input = copyInput.trim(); 491 if (!input) return; 492 493 isCopying = true; 494 try { 495 // Parse "handle" or "handle/page" 496 const parts = input.split('/'); 497 const handle = parts[0]; 498 const pageName = parts[1] || 'self'; 499 500 const did = await resolveHandle({ handle: handle as `${string}.${string}` }); 501 if (!did) throw new Error('Could not resolve handle'); 502 503 const records = await listRecords({ did, collection: 'app.blento.card' }); 504 const targetPage = 'blento.' + pageName; 505 506 const copiedCards: Item[] = records 507 .map((r) => ({ ...r.value }) as Item) 508 .filter((card) => { 509 // v0/v1 cards without page field belong to blento.self 510 if (!card.page) return targetPage === 'blento.self'; 511 return card.page === targetPage; 512 }) 513 .map((card) => { 514 // Apply v0→v1 migration (coords were halved in old format) 515 if (!card.version) { 516 card.x *= 2; 517 card.y *= 2; 518 card.h *= 2; 519 card.w *= 2; 520 card.mobileX *= 2; 521 card.mobileY *= 2; 522 card.mobileH *= 2; 523 card.mobileW *= 2; 524 card.version = 1; 525 } 526 527 // Convert blob refs to CDN URLs using source DID 528 if (card.cardData) { 529 for (const key of Object.keys(card.cardData)) { 530 const val = card.cardData[key]; 531 if (val && typeof val === 'object' && val.$type === 'blob') { 532 const url = getCDNImageBlobUrl({ did, blob: val }); 533 if (url) card.cardData[key] = url; 534 } 535 } 536 } 537 538 // Regenerate ID and assign to current page 539 card.id = TID.now(); 540 card.page = data.page; 541 return card; 542 }); 543 544 if (copiedCards.length === 0) { 545 toast.error('No cards found on that page'); 546 return; 547 } 548 549 fixAllCollisions(copiedCards); 550 fixAllCollisions(copiedCards, true); 551 compactItems(copiedCards); 552 compactItems(copiedCards, true); 553 554 items = copiedCards; 555 onLayoutChanged(); 556 toast.success(`Copied ${copiedCards.length} cards from ${handle}`); 557 } catch (e) { 558 console.error('Failed to copy page:', e); 559 toast.error('Failed to copy page'); 560 } finally { 561 isCopying = false; 562 } 563 } 564 565 let lastGridPos: { 566 x: number; 567 y: number; 568 swapWithId: string | null; 569 placement: string | null; 570 } | null = $state(null); 571 572 let debugPoint = $state({ x: 0, y: 0 }); 573 574 function getGridPosition( 575 clientX: number, 576 clientY: number 577 ): 578 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 579 | undefined { 580 if (!container || !activeDragElement.item) return; 581 582 // x, y represent the top-left corner of the dragged card 583 const x = clientX + activeDragElement.mouseDeltaX; 584 const y = clientY + activeDragElement.mouseDeltaY; 585 586 const rect = container.getBoundingClientRect(); 587 const currentMargin = isMobile ? mobileMargin : margin; 588 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 589 590 // Get card dimensions based on current view mode 591 const cardW = isMobile 592 ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 593 : activeDragElement.w; 594 const cardH = isMobile 595 ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 596 : activeDragElement.h; 597 598 // Get dragged card's original position 599 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 600 601 const draggedOrigY = draggedOrigPos 602 ? isMobile 603 ? draggedOrigPos.mobileY 604 : draggedOrigPos.y 605 : 0; 606 607 // Calculate raw grid position based on top-left of dragged card 608 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 609 gridX = Math.floor(gridX / 2) * 2; 610 611 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 612 613 if (isMobile) { 614 gridX = Math.floor(gridX / 2) * 2; 615 gridY = Math.floor(gridY / 2) * 2; 616 } 617 618 // Find if we're hovering over another card (using ORIGINAL positions) 619 const centerGridY = gridY + cardH / 2; 620 const centerGridX = gridX + cardW / 2; 621 622 let swapWithId: string | null = null; 623 let placement: 'above' | 'below' | null = null; 624 625 for (const other of items) { 626 if (other === activeDragElement.item) continue; 627 628 // Use original positions for hit testing 629 const origPos = activeDragElement.originalPositions.get(other.id); 630 if (!origPos) continue; 631 632 const otherX = isMobile ? origPos.mobileX : origPos.x; 633 const otherY = isMobile ? origPos.mobileY : origPos.y; 634 const otherW = isMobile ? other.mobileW : other.w; 635 const otherH = isMobile ? other.mobileH : other.h; 636 637 // Check if dragged card's center point is within this card's original bounds 638 if ( 639 centerGridX >= otherX && 640 centerGridX < otherX + otherW && 641 centerGridY >= otherY && 642 centerGridY < otherY + otherH 643 ) { 644 // Check if this is a swap situation: 645 // Cards have the same dimensions and are on the same row 646 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 647 648 if (canSwap) { 649 // Swap positions 650 swapWithId = other.id; 651 gridX = otherX; 652 gridY = otherY; 653 placement = null; 654 655 activeDragElement.lastTargetId = other.id; 656 activeDragElement.lastPlacement = null; 657 } else { 658 // Vertical placement (above/below) 659 // Detect drag direction: if dragging up, always place above 660 const isDraggingUp = gridY < draggedOrigY; 661 662 if (isDraggingUp) { 663 // When dragging up, always place above 664 placement = 'above'; 665 } else { 666 // When dragging down, use top/bottom half logic 667 const midpointY = otherY + otherH / 2; 668 const hysteresis = 0.3; 669 670 if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 671 if (activeDragElement.lastPlacement === 'above') { 672 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 673 } else { 674 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 675 } 676 } else { 677 placement = centerGridY < midpointY ? 'above' : 'below'; 678 } 679 } 680 681 activeDragElement.lastTargetId = other.id; 682 activeDragElement.lastPlacement = placement; 683 684 if (placement === 'above') { 685 gridY = otherY; 686 } else { 687 gridY = otherY + otherH; 688 } 689 } 690 break; 691 } 692 } 693 694 // If we're not over any card, clear the tracking 695 if (!swapWithId && !placement) { 696 activeDragElement.lastTargetId = null; 697 activeDragElement.lastPlacement = null; 698 } 699 700 debugPoint.x = x - rect.left; 701 debugPoint.y = y - rect.top + currentMargin; 702 703 return { x: gridX, y: gridY, swapWithId, placement }; 704 } 705 706 function getDragXY( 707 e: DragEvent & { 708 currentTarget: EventTarget & HTMLDivElement; 709 } 710 ) { 711 return getGridPosition(e.clientX, e.clientY); 712 } 713 714 // Touch drag system (instant drag on selected card) 715 let touchDragActive = $state(false); 716 717 function touchStart(e: TouchEvent) { 718 if (!selectedCardId || !container) return; 719 const touch = e.touches[0]; 720 if (!touch) return; 721 722 // Check if the touch is on the selected card element 723 const target = (e.target as HTMLElement)?.closest?.('.card'); 724 if (!target || target.id !== selectedCardId) return; 725 726 const item = items.find((i) => i.id === selectedCardId); 727 if (!item || item.cardData?.locked) return; 728 729 // Start dragging immediately 730 touchDragActive = true; 731 732 const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 733 if (!cardEl) return; 734 735 activeDragElement.element = cardEl; 736 activeDragElement.w = item.w; 737 activeDragElement.h = item.h; 738 activeDragElement.item = item; 739 740 // Store original positions of all items 741 activeDragElement.originalPositions = new Map(); 742 for (const it of items) { 743 activeDragElement.originalPositions.set(it.id, { 744 x: it.x, 745 y: it.y, 746 mobileX: it.mobileX, 747 mobileY: it.mobileY 748 }); 749 } 750 751 const rect = cardEl.getBoundingClientRect(); 752 activeDragElement.mouseDeltaX = rect.left - touch.clientX; 753 activeDragElement.mouseDeltaY = rect.top - touch.clientY; 754 } 755 756 function touchMove(e: TouchEvent) { 757 if (!touchDragActive) return; 758 759 const touch = e.touches[0]; 760 if (!touch) return; 761 762 e.preventDefault(); 763 764 // Auto-scroll near edges (always process, even if grid pos unchanged) 765 const scrollZone = 100; 766 const scrollSpeed = 10; 767 const viewportHeight = window.innerHeight; 768 769 if (touch.clientY < scrollZone) { 770 const intensity = 1 - touch.clientY / scrollZone; 771 window.scrollBy(0, -scrollSpeed * intensity); 772 } else if (touch.clientY > viewportHeight - scrollZone) { 773 const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 774 window.scrollBy(0, scrollSpeed * intensity); 775 } 776 777 const result = getGridPosition(touch.clientX, touch.clientY); 778 if (!result || !activeDragElement.item) return; 779 780 // Skip redundant work if grid position hasn't changed 781 if ( 782 lastGridPos && 783 lastGridPos.x === result.x && 784 lastGridPos.y === result.y && 785 lastGridPos.swapWithId === result.swapWithId && 786 lastGridPos.placement === result.placement 787 ) { 788 return; 789 } 790 lastGridPos = { 791 x: result.x, 792 y: result.y, 793 swapWithId: result.swapWithId, 794 placement: result.placement 795 }; 796 797 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 798 799 // Reset all items to original positions first 800 for (const it of items) { 801 const origPos = activeDragElement.originalPositions.get(it.id); 802 if (origPos && it !== activeDragElement.item) { 803 if (isMobile) { 804 it.mobileX = origPos.mobileX; 805 it.mobileY = origPos.mobileY; 806 } else { 807 it.x = origPos.x; 808 it.y = origPos.y; 809 } 810 } 811 } 812 813 // Update dragged item position 814 if (isMobile) { 815 activeDragElement.item.mobileX = result.x; 816 activeDragElement.item.mobileY = result.y; 817 } else { 818 activeDragElement.item.x = result.x; 819 activeDragElement.item.y = result.y; 820 } 821 822 // Handle horizontal swap 823 if (result.swapWithId && draggedOrigPos) { 824 const swapTarget = items.find((it) => it.id === result.swapWithId); 825 if (swapTarget) { 826 if (isMobile) { 827 swapTarget.mobileX = draggedOrigPos.mobileX; 828 swapTarget.mobileY = draggedOrigPos.mobileY; 829 } else { 830 swapTarget.x = draggedOrigPos.x; 831 swapTarget.y = draggedOrigPos.y; 832 } 833 } 834 } 835 836 fixCollisions(items, activeDragElement.item, isMobile); 837 } 838 839 function touchEnd() { 840 if (touchDragActive && activeDragElement.item) { 841 // Finalize position 842 fixCollisions(items, activeDragElement.item, isMobile); 843 onLayoutChanged(); 844 845 activeDragElement.x = -1; 846 activeDragElement.y = -1; 847 activeDragElement.element = null; 848 activeDragElement.item = null; 849 activeDragElement.lastTargetId = null; 850 activeDragElement.lastPlacement = null; 851 } 852 853 lastGridPos = null; 854 touchDragActive = false; 855 } 856 857 // Only register non-passive touchmove when actively dragging 858 $effect(() => { 859 const el = container; 860 if (!touchDragActive || !el) return; 861 862 el.addEventListener('touchmove', touchMove, { passive: false }); 863 return () => { 864 el.removeEventListener('touchmove', touchMove); 865 }; 866 }); 867 868 let linkValue = $state(''); 869 870 function addLink(url: string, specificCardDef?: CardDefinition) { 871 let link = validateLink(url); 872 if (!link) { 873 toast.error('invalid link'); 874 return; 875 } 876 let item = createEmptyCard(data.page); 877 878 if (specificCardDef?.onUrlHandler?.(link, item)) { 879 item.cardType = specificCardDef.type; 880 newItem.item = item; 881 saveNewItem(); 882 toast(specificCardDef.name + ' added!'); 883 return; 884 } 885 886 for (const cardDef of AllCardDefinitions.toSorted( 887 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 888 )) { 889 if (cardDef.onUrlHandler?.(link, item)) { 890 item.cardType = cardDef.type; 891 892 newItem.item = item; 893 saveNewItem(); 894 toast(cardDef.name + ' added!'); 895 break; 896 } 897 } 898 } 899 900 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 901 return new Promise((resolve) => { 902 const img = new Image(); 903 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 904 img.onerror = () => resolve({ width: 1, height: 1 }); 905 img.src = src; 906 }); 907 } 908 909 function getBestGridSize( 910 imageWidth: number, 911 imageHeight: number, 912 candidates: [number, number][] 913 ): [number, number] { 914 const imageRatio = imageWidth / imageHeight; 915 let best: [number, number] = candidates[0]; 916 let bestDiff = Infinity; 917 918 for (const candidate of candidates) { 919 const gridRatio = candidate[0] / candidate[1]; 920 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 921 if (diff < bestDiff) { 922 bestDiff = diff; 923 best = candidate; 924 } 925 } 926 927 return best; 928 } 929 930 const desktopSizeCandidates: [number, number][] = [ 931 [2, 2], 932 [2, 4], 933 [4, 2], 934 [4, 4], 935 [4, 6], 936 [6, 4] 937 ]; 938 const mobileSizeCandidates: [number, number][] = [ 939 [4, 4], 940 [4, 6], 941 [4, 8], 942 [6, 4], 943 [8, 4], 944 [8, 6] 945 ]; 946 947 async function processImageFile(file: File, gridX?: number, gridY?: number) { 948 const isGif = file.type === 'image/gif'; 949 950 // Don't compress GIFs to preserve animation 951 const objectUrl = URL.createObjectURL(file); 952 953 let item = createEmptyCard(data.page); 954 955 item.cardType = isGif ? 'gif' : 'image'; 956 item.cardData = { 957 image: { blob: file, objectUrl } 958 }; 959 960 // Size card based on image aspect ratio 961 const { width, height } = await getImageDimensions(objectUrl); 962 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 963 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 964 item.w = dw; 965 item.h = dh; 966 item.mobileW = mw; 967 item.mobileH = mh; 968 969 // If grid position is provided (image dropped on grid) 970 if (gridX !== undefined && gridY !== undefined) { 971 if (isMobile) { 972 item.mobileX = gridX; 973 item.mobileY = gridY; 974 // Derive desktop Y from mobile 975 item.x = Math.floor((COLUMNS - item.w) / 2); 976 item.x = Math.floor(item.x / 2) * 2; 977 item.y = Math.max(0, Math.round(gridY / 2)); 978 } else { 979 item.x = gridX; 980 item.y = gridY; 981 // Derive mobile Y from desktop 982 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 983 item.mobileX = Math.floor(item.mobileX / 2) * 2; 984 item.mobileY = Math.max(0, Math.round(gridY * 2)); 985 } 986 987 items = [...items, item]; 988 fixCollisions(items, item, isMobile); 989 fixCollisions(items, item, !isMobile); 990 } else { 991 const viewportCenter = getViewportCenterGridY(); 992 setPositionOfNewItem(item, items, viewportCenter); 993 items = [...items, item]; 994 fixCollisions(items, item, false, true); 995 fixCollisions(items, item, true, true); 996 compactItems(items, false); 997 compactItems(items, true); 998 } 999 1000 onLayoutChanged(); 1001 1002 await tick(); 1003 1004 scrollToItem(item, isMobile, container); 1005 } 1006 1007 function handleImageDragOver(event: DragEvent) { 1008 const dt = event.dataTransfer; 1009 if (!dt) return; 1010 1011 let hasImage = false; 1012 if (dt.items) { 1013 for (let i = 0; i < dt.items.length; i++) { 1014 const item = dt.items[i]; 1015 if (item && item.kind === 'file' && item.type.startsWith('image/')) { 1016 hasImage = true; 1017 break; 1018 } 1019 } 1020 } else if (dt.files) { 1021 for (let i = 0; i < dt.files.length; i++) { 1022 const file = dt.files[i]; 1023 if (file?.type.startsWith('image/')) { 1024 hasImage = true; 1025 break; 1026 } 1027 } 1028 } 1029 1030 if (hasImage) { 1031 event.preventDefault(); 1032 event.stopPropagation(); 1033 1034 imageDragOver = true; 1035 } 1036 } 1037 1038 function handleImageDragLeave(event: DragEvent) { 1039 event.preventDefault(); 1040 event.stopPropagation(); 1041 imageDragOver = false; 1042 } 1043 1044 async function handleImageDrop(event: DragEvent) { 1045 event.preventDefault(); 1046 event.stopPropagation(); 1047 const dropX = event.clientX; 1048 const dropY = event.clientY; 1049 imageDragOver = false; 1050 1051 if (!event.dataTransfer?.files?.length) return; 1052 1053 const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 1054 f?.type.startsWith('image/') 1055 ); 1056 if (imageFiles.length === 0) return; 1057 1058 // Calculate starting grid position from drop coordinates 1059 let gridX = 0; 1060 let gridY = 0; 1061 if (container) { 1062 const rect = container.getBoundingClientRect(); 1063 const currentMargin = isMobile ? mobileMargin : margin; 1064 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 1065 const cardW = isMobile ? 4 : 2; 1066 1067 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 1068 gridX = Math.floor(gridX / 2) * 2; 1069 1070 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 1071 if (isMobile) { 1072 gridY = Math.floor(gridY / 2) * 2; 1073 } 1074 } 1075 1076 for (let i = 0; i < imageFiles.length; i++) { 1077 // First image gets the drop position, rest use normal placement 1078 if (i === 0) { 1079 await processImageFile(imageFiles[i], gridX, gridY); 1080 } else { 1081 await processImageFile(imageFiles[i]); 1082 } 1083 } 1084 } 1085 1086 async function handleImageInputChange(event: Event) { 1087 const target = event.target as HTMLInputElement; 1088 if (!target.files || target.files.length < 1) return; 1089 1090 const files = Array.from(target.files); 1091 1092 if (files.length === 1) { 1093 // Single file: use default positioning 1094 await processImageFile(files[0]); 1095 } else { 1096 // Multiple files: place in grid pattern starting from first available position 1097 let gridX = 0; 1098 let gridY = maxHeight; 1099 const cardW = isMobile ? 4 : 2; 1100 const cardH = isMobile ? 4 : 2; 1101 1102 for (const file of files) { 1103 await processImageFile(file, gridX, gridY); 1104 1105 // Move to next cell position 1106 gridX += cardW; 1107 if (gridX + cardW > COLUMNS) { 1108 gridX = 0; 1109 gridY += cardH; 1110 } 1111 } 1112 } 1113 1114 // Reset the input so the same file can be selected again 1115 target.value = ''; 1116 } 1117 1118 async function processVideoFile(file: File) { 1119 const objectUrl = URL.createObjectURL(file); 1120 1121 let item = createEmptyCard(data.page); 1122 1123 item.cardType = 'video'; 1124 item.cardData = { 1125 blob: file, 1126 objectUrl 1127 }; 1128 1129 const viewportCenter = getViewportCenterGridY(); 1130 setPositionOfNewItem(item, items, viewportCenter); 1131 items = [...items, item]; 1132 fixCollisions(items, item, false, true); 1133 fixCollisions(items, item, true, true); 1134 compactItems(items, false); 1135 compactItems(items, true); 1136 1137 onLayoutChanged(); 1138 1139 await tick(); 1140 1141 scrollToItem(item, isMobile, container); 1142 } 1143 1144 async function handleVideoInputChange(event: Event) { 1145 const target = event.target as HTMLInputElement; 1146 if (!target.files || target.files.length < 1) return; 1147 1148 const files = Array.from(target.files); 1149 1150 for (const file of files) { 1151 await processVideoFile(file); 1152 } 1153 1154 // Reset the input so the same file can be selected again 1155 target.value = ''; 1156 } 1157 1158 let showCardCommand = $state(false); 1159</script> 1160 1161<svelte:body 1162 onpaste={(event) => { 1163 if (isTyping()) return; 1164 1165 const text = event.clipboardData?.getData('text/plain'); 1166 const link = validateLink(text, false); 1167 if (!link) return; 1168 1169 addLink(link); 1170 }} 1171/> 1172 1173<svelte:window 1174 ondragover={handleImageDragOver} 1175 ondragleave={handleImageDragLeave} 1176 ondrop={handleImageDrop} 1177/> 1178 1179<Head 1180 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 1181 title={getName(data)} 1182 image={'/' + data.handle + '/og.png'} 1183 accentColor={data.publication?.preferences?.accentColor} 1184 baseColor={data.publication?.preferences?.baseColor} 1185/> 1186 1187<Account {data} /> 1188 1189<Context {data} isEditing={true}> 1190 <CardCommand 1191 bind:open={showCardCommand} 1192 onselect={(cardDef: CardDefinition) => { 1193 if (cardDef.type === 'image') { 1194 const input = document.getElementById('image-input') as HTMLInputElement; 1195 if (input) { 1196 input.click(); 1197 return; 1198 } 1199 } else if (cardDef.type === 'video') { 1200 const input = document.getElementById('video-input') as HTMLInputElement; 1201 if (input) { 1202 input.click(); 1203 return; 1204 } 1205 } else { 1206 newCard(cardDef.type); 1207 } 1208 }} 1209 onlink={(url, cardDef) => { 1210 addLink(url, cardDef); 1211 }} 1212 /> 1213 1214 <Controls bind:data /> 1215 1216 {#if showingMobileView} 1217 <div 1218 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 1219 ></div> 1220 {/if} 1221 1222 {#if newItem.modal && newItem.item} 1223 <newItem.modal 1224 oncreate={() => { 1225 saveNewItem(); 1226 }} 1227 bind:item={newItem.item} 1228 oncancel={async () => { 1229 newItem = {}; 1230 await tick(); 1231 cleanupDialogArtifacts(); 1232 }} 1233 /> 1234 {/if} 1235 1236 <SaveModal 1237 bind:open={showSaveModal} 1238 success={saveSuccess} 1239 handle={data.handle} 1240 page={data.page} 1241 /> 1242 1243 <Modal open={showMobileWarning} closeButton={false}> 1244 <div class="flex flex-col items-center gap-4 text-center"> 1245 <svg 1246 xmlns="http://www.w3.org/2000/svg" 1247 fill="none" 1248 viewBox="0 0 24 24" 1249 stroke-width="1.5" 1250 stroke="currentColor" 1251 class="text-accent-500 size-10" 1252 > 1253 <path 1254 stroke-linecap="round" 1255 stroke-linejoin="round" 1256 d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3" 1257 /> 1258 </svg> 1259 <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p> 1260 <p class="text-base-500 dark:text-base-400 text-sm"> 1261 Mobile editing is currently experimental. For the best experience, use a desktop browser. 1262 </p> 1263 <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button> 1264 </div> 1265 </Modal> 1266 1267 <div 1268 class={[ 1269 '@container/wrapper relative w-full', 1270 showingMobileView 1271 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 1272 : '' 1273 ]} 1274 > 1275 {#if !getHideProfileSection(data)} 1276 <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> 1277 {/if} 1278 1279 <div 1280 class={[ 1281 'pointer-events-none relative mx-auto max-w-lg', 1282 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 1283 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 1284 : '@5xl/wrapper:max-w-4xl' 1285 ]} 1286 > 1287 <div class="pointer-events-none"></div> 1288 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 1289 <div 1290 bind:this={container} 1291 onclick={(e) => { 1292 // Deselect when tapping empty grid space 1293 if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 1294 selectedCardId = null; 1295 } 1296 }} 1297 ontouchstart={touchStart} 1298 ontouchend={touchEnd} 1299 ondragover={(e) => { 1300 e.preventDefault(); 1301 1302 // Auto-scroll when dragging near top or bottom of viewport (always process) 1303 const scrollZone = 100; 1304 const scrollSpeed = 10; 1305 const viewportHeight = window.innerHeight; 1306 1307 if (e.clientY < scrollZone) { 1308 const intensity = 1 - e.clientY / scrollZone; 1309 window.scrollBy(0, -scrollSpeed * intensity); 1310 } else if (e.clientY > viewportHeight - scrollZone) { 1311 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 1312 window.scrollBy(0, scrollSpeed * intensity); 1313 } 1314 1315 const result = getDragXY(e); 1316 if (!result) return; 1317 1318 // Skip redundant work if grid position hasn't changed 1319 if ( 1320 lastGridPos && 1321 lastGridPos.x === result.x && 1322 lastGridPos.y === result.y && 1323 lastGridPos.swapWithId === result.swapWithId && 1324 lastGridPos.placement === result.placement 1325 ) { 1326 return; 1327 } 1328 lastGridPos = { 1329 x: result.x, 1330 y: result.y, 1331 swapWithId: result.swapWithId, 1332 placement: result.placement 1333 }; 1334 1335 activeDragElement.x = result.x; 1336 activeDragElement.y = result.y; 1337 1338 if (activeDragElement.item) { 1339 // Get dragged card's original position for swapping 1340 const draggedOrigPos = activeDragElement.originalPositions.get( 1341 activeDragElement.item.id 1342 ); 1343 1344 // Reset all items to original positions first 1345 for (const it of items) { 1346 const origPos = activeDragElement.originalPositions.get(it.id); 1347 if (origPos && it !== activeDragElement.item) { 1348 if (isMobile) { 1349 it.mobileX = origPos.mobileX; 1350 it.mobileY = origPos.mobileY; 1351 } else { 1352 it.x = origPos.x; 1353 it.y = origPos.y; 1354 } 1355 } 1356 } 1357 1358 // Update dragged item position 1359 if (isMobile) { 1360 activeDragElement.item.mobileX = result.x; 1361 activeDragElement.item.mobileY = result.y; 1362 } else { 1363 activeDragElement.item.x = result.x; 1364 activeDragElement.item.y = result.y; 1365 } 1366 1367 // Handle horizontal swap 1368 if (result.swapWithId && draggedOrigPos) { 1369 const swapTarget = items.find((it) => it.id === result.swapWithId); 1370 if (swapTarget) { 1371 // Move swap target to dragged card's original position 1372 if (isMobile) { 1373 swapTarget.mobileX = draggedOrigPos.mobileX; 1374 swapTarget.mobileY = draggedOrigPos.mobileY; 1375 } else { 1376 swapTarget.x = draggedOrigPos.x; 1377 swapTarget.y = draggedOrigPos.y; 1378 } 1379 } 1380 } 1381 1382 // Now fix collisions (with compacting) 1383 fixCollisions(items, activeDragElement.item, isMobile); 1384 } 1385 }} 1386 ondragend={async (e) => { 1387 e.preventDefault(); 1388 // safari fix 1389 activeDragElement.x = -1; 1390 activeDragElement.y = -1; 1391 activeDragElement.element = null; 1392 activeDragElement.item = null; 1393 activeDragElement.lastTargetId = null; 1394 activeDragElement.lastPlacement = null; 1395 lastGridPos = null; 1396 return true; 1397 }} 1398 class={[ 1399 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 1400 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 1401 ]} 1402 > 1403 {#each items as item, i (item.id)} 1404 <!-- {#if item !== activeDragElement.item} --> 1405 <BaseEditingCard 1406 bind:item={items[i]} 1407 ondelete={() => { 1408 items = items.filter((it) => it !== item); 1409 compactItems(items, false); 1410 compactItems(items, true); 1411 onLayoutChanged(); 1412 }} 1413 onsetsize={(newW: number, newH: number) => { 1414 if (isMobile) { 1415 item.mobileW = newW; 1416 item.mobileH = newH; 1417 } else { 1418 item.w = newW; 1419 item.h = newH; 1420 } 1421 1422 fixCollisions(items, item, isMobile); 1423 onLayoutChanged(); 1424 }} 1425 ondragstart={(e: DragEvent) => { 1426 const target = e.currentTarget as HTMLDivElement; 1427 activeDragElement.element = target; 1428 activeDragElement.w = item.w; 1429 activeDragElement.h = item.h; 1430 activeDragElement.item = item; 1431 // fix for div shadow during drag and drop 1432 const transparent = document.createElement('div'); 1433 transparent.style.position = 'fixed'; 1434 transparent.style.top = '-1000px'; 1435 transparent.style.width = '1px'; 1436 transparent.style.height = '1px'; 1437 document.body.appendChild(transparent); 1438 e.dataTransfer?.setDragImage(transparent, 0, 0); 1439 requestAnimationFrame(() => transparent.remove()); 1440 1441 // Store original positions of all items 1442 activeDragElement.originalPositions = new Map(); 1443 for (const it of items) { 1444 activeDragElement.originalPositions.set(it.id, { 1445 x: it.x, 1446 y: it.y, 1447 mobileX: it.mobileX, 1448 mobileY: it.mobileY 1449 }); 1450 } 1451 1452 const rect = target.getBoundingClientRect(); 1453 activeDragElement.mouseDeltaX = rect.left - e.clientX; 1454 activeDragElement.mouseDeltaY = rect.top - e.clientY; 1455 }} 1456 > 1457 <EditingCard bind:item={items[i]} /> 1458 </BaseEditingCard> 1459 <!-- {/if} --> 1460 {/each} 1461 1462 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 1463 </div> 1464 </div> 1465 </div> 1466 1467 <EditBar 1468 {data} 1469 bind:linkValue 1470 bind:isSaving 1471 bind:showingMobileView 1472 {hasUnsavedChanges} 1473 {newCard} 1474 {addLink} 1475 {save} 1476 {handleImageInputChange} 1477 {handleVideoInputChange} 1478 showCardCommand={() => { 1479 showCardCommand = true; 1480 }} 1481 {selectedCard} 1482 {isMobile} 1483 {isCoarse} 1484 ondeselect={() => { 1485 selectedCardId = null; 1486 }} 1487 ondelete={() => { 1488 if (selectedCard) { 1489 items = items.filter((it) => it.id !== selectedCardId); 1490 compactItems(items, false); 1491 compactItems(items, true); 1492 onLayoutChanged(); 1493 selectedCardId = null; 1494 } 1495 }} 1496 onsetsize={(w: number, h: number) => { 1497 if (selectedCard) { 1498 if (isMobile) { 1499 selectedCard.mobileW = w; 1500 selectedCard.mobileH = h; 1501 } else { 1502 selectedCard.w = w; 1503 selectedCard.h = h; 1504 } 1505 fixCollisions(items, selectedCard, isMobile); 1506 onLayoutChanged(); 1507 } 1508 }} 1509 /> 1510 1511 <Toaster /> 1512 1513 <FloatingEditButton {data} /> 1514 1515 {#if dev} 1516 <div 1517 class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs" 1518 > 1519 <span>editedOn: {editedOn}</span> 1520 <button class="underline" onclick={addAllCardTypes}>+ all cards</button> 1521 <input 1522 bind:value={copyInput} 1523 placeholder="handle/page" 1524 class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5" 1525 onkeydown={(e) => { 1526 if (e.key === 'Enter') copyPageFrom(); 1527 }} 1528 /> 1529 <button class="underline" onclick={copyPageFrom} disabled={isCopying}> 1530 {isCopying ? 'copying...' : 'copy'} 1531 </button> 1532 </div> 1533 {/if} 1534</Context>