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