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