your personal website on atproto - mirror blento.app
at pages 966 lines 26 kB view raw
1<script lang="ts"> 2 import { Button, Modal, toast, Toaster } from '@foxui/core'; 3 import { COLUMNS } from '$lib'; 4 import { 5 checkAndUploadImage, 6 createEmptyCard, 7 getHideProfileSection, 8 getProfilePosition, 9 getName, 10 isTyping, 11 savePage, 12 scrollToItem, 13 validateLink, 14 getImage 15 } from '../helper'; 16 import EditableProfile from './EditableProfile.svelte'; 17 import type { Item, WebsiteData } from '../types'; 18 import { innerWidth } from 'svelte/reactivity/window'; 19 import EditingCard from '../cards/_base/Card/EditingCard.svelte'; 20 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 21 import { tick, type Component } from 'svelte'; 22 import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 23 import { dev } from '$app/environment'; 24 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 25 import BaseEditingCard from '../cards/_base/BaseCard/BaseEditingCard.svelte'; 26 import Context from './Context.svelte'; 27 import Head from './Head.svelte'; 28 import Account from './Account.svelte'; 29 import EditBar from './EditBar.svelte'; 30 import SaveModal from './SaveModal.svelte'; 31 import FloatingEditButton from './FloatingEditButton.svelte'; 32 import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 33 import * as TID from '@atcute/tid'; 34 import { launchConfetti } from '@foxui/visual'; 35 import Controls from './Controls.svelte'; 36 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 37 import { SvelteMap } from 'svelte/reactivity'; 38 import { 39 fixCollisions, 40 compactItems, 41 fixAllCollisions, 42 setPositionOfNewItem, 43 shouldMirror, 44 mirrorLayout, 45 getViewportCenterGridY, 46 EditableGrid 47 } from '$lib/layout'; 48 49 let { 50 data 51 }: { 52 data: WebsiteData; 53 } = $props(); 54 55 // Check if floating login button will be visible (to hide MadeWithBlento) 56 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 57 58 // svelte-ignore state_referenced_locally 59 let items: Item[] = $state(data.cards); 60 61 // svelte-ignore state_referenced_locally 62 let publication = $state(JSON.stringify(data.publication)); 63 64 // svelte-ignore state_referenced_locally 65 let savedItemsSnapshot = JSON.stringify(data.cards); 66 67 let hasUnsavedChanges = $state(false); 68 69 // Detect card content and publication changes (e.g. sidebar edits) 70 // The guard ensures JSON.stringify only runs while no changes are detected yet. 71 // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations 72 // but the early return makes it effectively free. 73 $effect(() => { 74 if (hasUnsavedChanges) return; 75 if ( 76 JSON.stringify(items) !== savedItemsSnapshot || 77 JSON.stringify(data.publication) !== publication 78 ) { 79 hasUnsavedChanges = true; 80 } 81 }); 82 83 // Warn user before closing tab if there are unsaved changes 84 $effect(() => { 85 function handleBeforeUnload(e: BeforeUnloadEvent) { 86 if (hasUnsavedChanges) { 87 e.preventDefault(); 88 return ''; 89 } 90 } 91 92 window.addEventListener('beforeunload', handleBeforeUnload); 93 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 94 }); 95 96 let gridContainer: HTMLDivElement | undefined = $state(); 97 98 let showingMobileView = $state(false); 99 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 100 let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024); 101 102 setIsMobile(() => isMobile); 103 104 // svelte-ignore state_referenced_locally 105 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 106 107 function onLayoutChanged() { 108 hasUnsavedChanges = true; 109 // Set the bit for the current layout: desktop=1, mobile=2 110 editedOn = editedOn | (isMobile ? 2 : 1); 111 if (shouldMirror(editedOn)) { 112 mirrorLayout(items, isMobile); 113 } 114 } 115 116 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 117 setIsCoarse(() => isCoarse); 118 119 let selectedCardId: string | null = $state(null); 120 let selectedCard = $derived( 121 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 122 ); 123 124 setSelectedCardId(() => selectedCardId); 125 setSelectCard((id: string | null) => { 126 selectedCardId = id; 127 }); 128 129 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 130 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 131 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 132 133 function newCard(type: string = 'link', cardData?: any) { 134 selectedCardId = null; 135 136 // close sidebar if open 137 const popover = document.getElementById('mobile-menu'); 138 if (popover) { 139 popover.hidePopover(); 140 } 141 142 let item = createEmptyCard(data.page); 143 item.cardType = type; 144 145 item.cardData = cardData ?? {}; 146 147 const cardDef = CardDefinitionsByType[type]; 148 cardDef?.createNew?.(item); 149 150 newItem.item = item; 151 152 if (cardDef?.creationModalComponent) { 153 newItem.modal = cardDef.creationModalComponent; 154 } else { 155 saveNewItem(); 156 } 157 } 158 159 function cleanupDialogArtifacts() { 160 // bits-ui's body scroll lock and portal may not clean up fully when the 161 // modal is unmounted instead of closed via the open prop. 162 const restore = () => { 163 document.body.style.removeProperty('overflow'); 164 document.body.style.removeProperty('pointer-events'); 165 document.body.style.removeProperty('padding-right'); 166 document.body.style.removeProperty('margin-right'); 167 // Remove any orphaned dialog overlay/content elements left by the portal 168 for (const el of document.querySelectorAll('[data-dialog-overlay], [data-dialog-content]')) { 169 el.remove(); 170 } 171 }; 172 // Run immediately and again after bits-ui's 24ms scheduled cleanup 173 restore(); 174 setTimeout(restore, 50); 175 } 176 177 async function saveNewItem() { 178 if (!newItem.item) return; 179 const item = newItem.item; 180 181 const viewportCenter = gridContainer 182 ? getViewportCenterGridY(gridContainer, isMobile) 183 : undefined; 184 setPositionOfNewItem(item, items, viewportCenter); 185 186 items = [...items, item]; 187 188 // Push overlapping items down, then compact to fill gaps 189 fixCollisions(items, item, false, true); 190 fixCollisions(items, item, true, true); 191 compactItems(items, false); 192 compactItems(items, true); 193 194 onLayoutChanged(); 195 196 newItem = {}; 197 198 await tick(); 199 cleanupDialogArtifacts(); 200 201 scrollToItem(item, isMobile, gridContainer); 202 } 203 204 let isSaving = $state(false); 205 let showSaveModal = $state(false); 206 let saveSuccess = $state(false); 207 208 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 209 210 async function save() { 211 isSaving = true; 212 saveSuccess = false; 213 showSaveModal = true; 214 215 try { 216 // Upload profile icon if changed 217 if (data.publication?.icon) { 218 await checkAndUploadImage(data.publication, 'icon'); 219 } 220 221 // Persist layout editing state 222 data.publication.preferences ??= {}; 223 data.publication.preferences.editedOn = editedOn; 224 225 await savePage(data, items, publication); 226 227 publication = JSON.stringify(data.publication); 228 229 savedItemsSnapshot = JSON.stringify(items); 230 hasUnsavedChanges = false; 231 232 saveSuccess = true; 233 234 launchConfetti(); 235 236 // Refresh cached data 237 await fetch('/' + data.handle + '/api/refresh'); 238 } catch (error) { 239 console.error(error); 240 showSaveModal = false; 241 toast.error('Error saving page!'); 242 } finally { 243 isSaving = false; 244 } 245 } 246 247 function addAllCardTypes() { 248 const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; 249 const grouped = new SvelteMap<string, CardDefinition[]>(); 250 251 for (const def of AllCardDefinitions) { 252 if (!def.name) continue; 253 const group = def.groups?.[0] ?? 'Other'; 254 if (!grouped.has(group)) grouped.set(group, []); 255 grouped.get(group)!.push(def); 256 } 257 258 // Sort groups by predefined order, unknowns at end 259 const sortedGroups = [...grouped.keys()].sort((a, b) => { 260 const ai = groupOrder.indexOf(a); 261 const bi = groupOrder.indexOf(b); 262 return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); 263 }); 264 265 // Sample data for cards that would otherwise render empty 266 const sampleData: Record<string, Record<string, unknown>> = { 267 text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' }, 268 link: { 269 href: 'https://bsky.app', 270 title: 'Bluesky', 271 domain: 'bsky.app', 272 description: 'Social networking that gives you choice', 273 hasFetched: true 274 }, 275 image: { 276 image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600', 277 alt: 'Mountain landscape' 278 }, 279 button: { text: 'Visit Bluesky', href: 'https://bsky.app' }, 280 bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' }, 281 blueskyPost: { 282 uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y', 283 href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y' 284 }, 285 blueskyProfile: { 286 handle: 'bsky.app', 287 displayName: 'Bluesky', 288 avatar: 289 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg' 290 }, 291 blueskyMedia: {}, 292 latestPost: {}, 293 youtubeVideo: { 294 youtubeId: 'dQw4w9WgXcQ', 295 poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 296 href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 297 showInline: true 298 }, 299 'spotify-list-embed': { 300 spotifyType: 'album', 301 spotifyId: '4aawyAB9vmqN3uQ7FjRGTy', 302 href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy' 303 }, 304 latestLivestream: {}, 305 livestreamEmbed: { 306 href: 'https://stream.place/', 307 embed: 'https://stream.place/embed/' 308 }, 309 mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' }, 310 gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' }, 311 event: { 312 uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q' 313 }, 314 guestbook: { label: 'Guestbook' }, 315 githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' }, 316 photoGallery: { 317 galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w' 318 }, 319 atprotocollections: {}, 320 publicationList: {}, 321 recentPopfeedReviews: {}, 322 recentTealFMPlays: {}, 323 statusphere: { emoji: '✨' }, 324 vcard: {}, 325 'fluid-text': { text: 'Hello World' }, 326 draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true }, 327 clock: {}, 328 countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() }, 329 timer: {}, 330 'dino-game': {}, 331 tetris: {}, 332 updatedBlentos: {} 333 }; 334 335 // Labels for cards that support canHaveLabel 336 const sampleLabels: Record<string, string> = { 337 image: 'Mountain Landscape', 338 mapLocation: 'Eiffel Tower', 339 gif: 'Cat Typing', 340 bigsocial: 'Bluesky', 341 guestbook: 'Guestbook', 342 statusphere: 'My Status', 343 recentPopfeedReviews: 'My Reviews', 344 recentTealFMPlays: 'Recently Played', 345 clock: 'Local Time', 346 countdown: 'Launch Day', 347 timer: 'Timer', 348 'dino-game': 'Dino Game', 349 tetris: 'Tetris', 350 blueskyMedia: 'Bluesky Media' 351 }; 352 353 const newItems: Item[] = []; 354 let cursorY = 0; 355 let mobileCursorY = 0; 356 357 for (const group of sortedGroups) { 358 const defs = grouped.get(group)!; 359 360 // Add a section heading for the group 361 const heading = createEmptyCard(data.page); 362 heading.cardType = 'section'; 363 heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 }; 364 heading.w = COLUMNS; 365 heading.h = 1; 366 heading.x = 0; 367 heading.y = cursorY; 368 heading.mobileW = COLUMNS; 369 heading.mobileH = 2; 370 heading.mobileX = 0; 371 heading.mobileY = mobileCursorY; 372 newItems.push(heading); 373 cursorY += 1; 374 mobileCursorY += 2; 375 376 // Place cards in rows 377 let rowX = 0; 378 let rowMaxH = 0; 379 let mobileRowX = 0; 380 let mobileRowMaxH = 0; 381 382 for (const def of defs) { 383 if (def.type === 'section' || def.type === 'embed') continue; 384 385 const item = createEmptyCard(data.page); 386 item.cardType = def.type; 387 item.cardData = {}; 388 def.createNew?.(item); 389 390 // Merge in sample data (without overwriting createNew defaults) 391 const extra = sampleData[def.type]; 392 if (extra) { 393 item.cardData = { ...item.cardData, ...extra }; 394 } 395 396 // Set item-level color for cards that need it 397 if (def.type === 'button') { 398 item.color = 'transparent'; 399 } 400 401 // Add label if card supports it 402 const label = sampleLabels[def.type]; 403 if (label && def.canHaveLabel) { 404 item.cardData.label = label; 405 } 406 407 // Desktop layout 408 if (rowX + item.w > COLUMNS) { 409 cursorY += rowMaxH; 410 rowX = 0; 411 rowMaxH = 0; 412 } 413 item.x = rowX; 414 item.y = cursorY; 415 rowX += item.w; 416 rowMaxH = Math.max(rowMaxH, item.h); 417 418 // Mobile layout 419 if (mobileRowX + item.mobileW > COLUMNS) { 420 mobileCursorY += mobileRowMaxH; 421 mobileRowX = 0; 422 mobileRowMaxH = 0; 423 } 424 item.mobileX = mobileRowX; 425 item.mobileY = mobileCursorY; 426 mobileRowX += item.mobileW; 427 mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH); 428 429 newItems.push(item); 430 } 431 432 // Move cursor past last row 433 cursorY += rowMaxH; 434 mobileCursorY += mobileRowMaxH; 435 } 436 437 items = newItems; 438 onLayoutChanged(); 439 } 440 441 let copyInput = $state(''); 442 let isCopying = $state(false); 443 444 async function copyPageFrom() { 445 const input = copyInput.trim(); 446 if (!input) return; 447 448 isCopying = true; 449 try { 450 // Parse "handle" or "handle/page" 451 const parts = input.split('/'); 452 const handle = parts[0]; 453 const pageName = parts[1] || 'self'; 454 455 const did = await resolveHandle({ handle: handle as `${string}.${string}` }); 456 if (!did) throw new Error('Could not resolve handle'); 457 458 const records = await listRecords({ did, collection: 'app.blento.card' }); 459 const targetPage = 'blento.' + pageName; 460 461 const copiedCards: Item[] = records 462 .map((r) => ({ ...r.value }) as Item) 463 .filter((card) => { 464 // v0/v1 cards without page field belong to blento.self 465 if (!card.page) return targetPage === 'blento.self'; 466 return card.page === targetPage; 467 }) 468 .map((card) => { 469 // Apply v0→v1 migration (coords were halved in old format) 470 if (!card.version) { 471 card.x *= 2; 472 card.y *= 2; 473 card.h *= 2; 474 card.w *= 2; 475 card.mobileX *= 2; 476 card.mobileY *= 2; 477 card.mobileH *= 2; 478 card.mobileW *= 2; 479 card.version = 1; 480 } 481 482 // Convert blob refs to CDN URLs using source DID 483 if (card.cardData) { 484 for (const key of Object.keys(card.cardData)) { 485 const val = card.cardData[key]; 486 if (val && typeof val === 'object' && val.$type === 'blob') { 487 const url = getCDNImageBlobUrl({ did, blob: val }); 488 if (url) card.cardData[key] = url; 489 } 490 } 491 } 492 493 // Regenerate ID and assign to current page 494 card.id = TID.now(); 495 card.page = data.page; 496 return card; 497 }); 498 499 if (copiedCards.length === 0) { 500 toast.error('No cards found on that page'); 501 return; 502 } 503 504 fixAllCollisions(copiedCards, false); 505 fixAllCollisions(copiedCards, true); 506 compactItems(copiedCards, false); 507 compactItems(copiedCards, true); 508 509 items = copiedCards; 510 onLayoutChanged(); 511 toast.success(`Copied ${copiedCards.length} cards from ${handle}`); 512 } catch (e) { 513 console.error('Failed to copy page:', e); 514 toast.error('Failed to copy page'); 515 } finally { 516 isCopying = false; 517 } 518 } 519 520 let linkValue = $state(''); 521 522 function addLink(url: string, specificCardDef?: CardDefinition) { 523 let link = validateLink(url); 524 if (!link) { 525 toast.error('invalid link'); 526 return; 527 } 528 let item = createEmptyCard(data.page); 529 530 if (specificCardDef?.onUrlHandler?.(link, item)) { 531 item.cardType = specificCardDef.type; 532 newItem.item = item; 533 saveNewItem(); 534 toast(specificCardDef.name + ' added!'); 535 return; 536 } 537 538 for (const cardDef of AllCardDefinitions.toSorted( 539 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 540 )) { 541 if (cardDef.onUrlHandler?.(link, item)) { 542 item.cardType = cardDef.type; 543 544 newItem.item = item; 545 saveNewItem(); 546 toast(cardDef.name + ' added!'); 547 break; 548 } 549 } 550 } 551 552 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 553 return new Promise((resolve) => { 554 const img = new Image(); 555 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 556 img.onerror = () => resolve({ width: 1, height: 1 }); 557 img.src = src; 558 }); 559 } 560 561 function getBestGridSize( 562 imageWidth: number, 563 imageHeight: number, 564 candidates: [number, number][] 565 ): [number, number] { 566 const imageRatio = imageWidth / imageHeight; 567 let best: [number, number] = candidates[0]; 568 let bestDiff = Infinity; 569 570 for (const candidate of candidates) { 571 const gridRatio = candidate[0] / candidate[1]; 572 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 573 if (diff < bestDiff) { 574 bestDiff = diff; 575 best = candidate; 576 } 577 } 578 579 return best; 580 } 581 582 const desktopSizeCandidates: [number, number][] = [ 583 [2, 2], 584 [2, 4], 585 [4, 2], 586 [4, 4], 587 [4, 6], 588 [6, 4] 589 ]; 590 const mobileSizeCandidates: [number, number][] = [ 591 [4, 4], 592 [4, 6], 593 [4, 8], 594 [6, 4], 595 [8, 4], 596 [8, 6] 597 ]; 598 599 async function processImageFile(file: File, gridX?: number, gridY?: number) { 600 const isGif = file.type === 'image/gif'; 601 602 // Don't compress GIFs to preserve animation 603 const objectUrl = URL.createObjectURL(file); 604 605 let item = createEmptyCard(data.page); 606 607 item.cardType = isGif ? 'gif' : 'image'; 608 item.cardData = { 609 image: { blob: file, objectUrl } 610 }; 611 612 // Size card based on image aspect ratio 613 const { width, height } = await getImageDimensions(objectUrl); 614 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 615 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 616 item.w = dw; 617 item.h = dh; 618 item.mobileW = mw; 619 item.mobileH = mh; 620 621 // If grid position is provided (image dropped on grid) 622 if (gridX !== undefined && gridY !== undefined) { 623 if (isMobile) { 624 item.mobileX = gridX; 625 item.mobileY = gridY; 626 // Derive desktop Y from mobile 627 item.x = Math.floor((COLUMNS - item.w) / 2); 628 item.x = Math.floor(item.x / 2) * 2; 629 item.y = Math.max(0, Math.round(gridY / 2)); 630 } else { 631 item.x = gridX; 632 item.y = gridY; 633 // Derive mobile Y from desktop 634 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 635 item.mobileX = Math.floor(item.mobileX / 2) * 2; 636 item.mobileY = Math.max(0, Math.round(gridY * 2)); 637 } 638 639 items = [...items, item]; 640 fixCollisions(items, item, isMobile); 641 fixCollisions(items, item, !isMobile); 642 } else { 643 const viewportCenter = gridContainer 644 ? getViewportCenterGridY(gridContainer, isMobile) 645 : undefined; 646 setPositionOfNewItem(item, items, viewportCenter); 647 items = [...items, item]; 648 fixCollisions(items, item, false, true); 649 fixCollisions(items, item, true, true); 650 compactItems(items, false); 651 compactItems(items, true); 652 } 653 654 onLayoutChanged(); 655 656 await tick(); 657 658 scrollToItem(item, isMobile, gridContainer); 659 } 660 661 async function handleFileDrop(files: File[], gridX: number, gridY: number) { 662 for (let i = 0; i < files.length; i++) { 663 // First image gets the drop position, rest use normal placement 664 if (i === 0) { 665 await processImageFile(files[i], gridX, gridY); 666 } else { 667 await processImageFile(files[i]); 668 } 669 } 670 } 671 672 async function handleImageInputChange(event: Event) { 673 const target = event.target as HTMLInputElement; 674 if (!target.files || target.files.length < 1) return; 675 676 const files = Array.from(target.files); 677 678 if (files.length === 1) { 679 // Single file: use default positioning 680 await processImageFile(files[0]); 681 } else { 682 // Multiple files: place in grid pattern starting from first available position 683 let gridX = 0; 684 let gridY = maxHeight; 685 const cardW = isMobile ? 4 : 2; 686 const cardH = isMobile ? 4 : 2; 687 688 for (const file of files) { 689 await processImageFile(file, gridX, gridY); 690 691 // Move to next cell position 692 gridX += cardW; 693 if (gridX + cardW > COLUMNS) { 694 gridX = 0; 695 gridY += cardH; 696 } 697 } 698 } 699 700 // Reset the input so the same file can be selected again 701 target.value = ''; 702 } 703 704 async function processVideoFile(file: File) { 705 const objectUrl = URL.createObjectURL(file); 706 707 let item = createEmptyCard(data.page); 708 709 item.cardType = 'video'; 710 item.cardData = { 711 blob: file, 712 objectUrl 713 }; 714 715 const viewportCenter = gridContainer 716 ? getViewportCenterGridY(gridContainer, isMobile) 717 : undefined; 718 setPositionOfNewItem(item, items, viewportCenter); 719 items = [...items, item]; 720 fixCollisions(items, item, false, true); 721 fixCollisions(items, item, true, true); 722 compactItems(items, false); 723 compactItems(items, true); 724 725 onLayoutChanged(); 726 727 await tick(); 728 729 scrollToItem(item, isMobile, gridContainer); 730 } 731 732 async function handleVideoInputChange(event: Event) { 733 const target = event.target as HTMLInputElement; 734 if (!target.files || target.files.length < 1) return; 735 736 const files = Array.from(target.files); 737 738 for (const file of files) { 739 await processVideoFile(file); 740 } 741 742 // Reset the input so the same file can be selected again 743 target.value = ''; 744 } 745 746 let showCardCommand = $state(false); 747</script> 748 749<svelte:body 750 onpaste={(event) => { 751 if (isTyping()) return; 752 753 const text = event.clipboardData?.getData('text/plain'); 754 const link = validateLink(text, false); 755 if (!link) return; 756 757 addLink(link); 758 }} 759/> 760 761<Head 762 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 763 title={getName(data)} 764 image={'/' + data.handle + '/og.png'} 765 accentColor={data.publication?.preferences?.accentColor} 766 baseColor={data.publication?.preferences?.baseColor} 767/> 768 769<Account {data} /> 770 771<Context {data} isEditing={true}> 772 <CardCommand 773 bind:open={showCardCommand} 774 onselect={(cardDef: CardDefinition) => { 775 if (cardDef.type === 'image') { 776 const input = document.getElementById('image-input') as HTMLInputElement; 777 if (input) { 778 input.click(); 779 return; 780 } 781 } else if (cardDef.type === 'video') { 782 const input = document.getElementById('video-input') as HTMLInputElement; 783 if (input) { 784 input.click(); 785 return; 786 } 787 } else { 788 newCard(cardDef.type); 789 } 790 }} 791 onlink={(url, cardDef) => { 792 addLink(url, cardDef); 793 }} 794 /> 795 796 <Controls bind:data /> 797 798 {#if showingMobileView} 799 <div 800 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 801 ></div> 802 {/if} 803 804 {#if newItem.modal && newItem.item} 805 <newItem.modal 806 oncreate={() => { 807 saveNewItem(); 808 }} 809 bind:item={newItem.item} 810 oncancel={async () => { 811 newItem = {}; 812 await tick(); 813 cleanupDialogArtifacts(); 814 }} 815 /> 816 {/if} 817 818 <SaveModal 819 bind:open={showSaveModal} 820 success={saveSuccess} 821 handle={data.handle} 822 page={data.page} 823 /> 824 825 <Modal open={showMobileWarning} closeButton={false}> 826 <div class="flex flex-col items-center gap-4 text-center"> 827 <svg 828 xmlns="http://www.w3.org/2000/svg" 829 fill="none" 830 viewBox="0 0 24 24" 831 stroke-width="1.5" 832 stroke="currentColor" 833 class="text-accent-500 size-10" 834 > 835 <path 836 stroke-linecap="round" 837 stroke-linejoin="round" 838 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" 839 /> 840 </svg> 841 <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p> 842 <p class="text-base-500 dark:text-base-400 text-sm"> 843 Mobile editing is currently experimental. For the best experience, use a desktop browser. 844 </p> 845 <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button> 846 </div> 847 </Modal> 848 849 <div 850 class={[ 851 '@container/wrapper relative w-full', 852 showingMobileView 853 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 854 : '' 855 ]} 856 > 857 {#if !getHideProfileSection(data)} 858 <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> 859 {/if} 860 861 <div 862 class={[ 863 'pointer-events-none relative mx-auto max-w-lg', 864 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 865 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 866 : '@5xl/wrapper:max-w-4xl' 867 ]} 868 > 869 <div class="pointer-events-none"></div> 870 <EditableGrid 871 bind:items 872 bind:ref={gridContainer} 873 {isMobile} 874 {selectedCardId} 875 {isCoarse} 876 onlayoutchange={onLayoutChanged} 877 ondeselect={() => { 878 selectedCardId = null; 879 }} 880 onfiledrop={handleFileDrop} 881 > 882 {#each items as item, i (item.id)} 883 <BaseEditingCard 884 bind:item={items[i]} 885 ondelete={() => { 886 items = items.filter((it) => it !== item); 887 compactItems(items, false); 888 compactItems(items, true); 889 onLayoutChanged(); 890 }} 891 onsetsize={(newW: number, newH: number) => { 892 if (isMobile) { 893 item.mobileW = newW; 894 item.mobileH = newH; 895 } else { 896 item.w = newW; 897 item.h = newH; 898 } 899 900 fixCollisions(items, item, isMobile); 901 onLayoutChanged(); 902 }} 903 > 904 <EditingCard bind:item={items[i]} /> 905 </BaseEditingCard> 906 {/each} 907 </EditableGrid> 908 </div> 909 </div> 910 911 <EditBar 912 {data} 913 bind:linkValue 914 bind:isSaving 915 bind:showingMobileView 916 {hasUnsavedChanges} 917 {newCard} 918 {addLink} 919 {save} 920 {handleImageInputChange} 921 {handleVideoInputChange} 922 showCardCommand={() => { 923 showCardCommand = true; 924 }} 925 {selectedCard} 926 {isMobile} 927 {isCoarse} 928 ondeselect={() => { 929 selectedCardId = null; 930 }} 931 ondelete={() => { 932 if (selectedCard) { 933 items = items.filter((it) => it.id !== selectedCardId); 934 compactItems(items, false); 935 compactItems(items, true); 936 onLayoutChanged(); 937 selectedCardId = null; 938 } 939 }} 940 onsetsize={(w: number, h: number) => { 941 if (selectedCard) { 942 if (isMobile) { 943 selectedCard.mobileW = w; 944 selectedCard.mobileH = h; 945 } else { 946 selectedCard.w = w; 947 selectedCard.h = h; 948 } 949 fixCollisions(items, selectedCard, isMobile); 950 onLayoutChanged(); 951 } 952 }} 953 /> 954 955 <Toaster /> 956 957 <FloatingEditButton {data} /> 958 959 {#if dev} 960 <div 961 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" 962 > 963 <span>editedOn: {editedOn}</span> 964 </div> 965 {/if} 966</Context>