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