your personal website on atproto - mirror blento.app
at small-improvements 700 lines 19 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 ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte'; 38 import { SvelteMap } from 'svelte/reactivity'; 39 import { 40 fixCollisions, 41 compactItems, 42 fixAllCollisions, 43 setPositionOfNewItem, 44 shouldMirror, 45 mirrorLayout, 46 getViewportCenterGridY, 47 EditableGrid 48 } from '$lib/layout'; 49 50 let { 51 data 52 }: { 53 data: WebsiteData; 54 } = $props(); 55 56 // Check if floating login button will be visible (to hide MadeWithBlento) 57 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 58 59 // svelte-ignore state_referenced_locally 60 let items: Item[] = $state(data.cards); 61 62 // svelte-ignore state_referenced_locally 63 let publication = $state(JSON.stringify(data.publication)); 64 65 // svelte-ignore state_referenced_locally 66 let savedItemsSnapshot = JSON.stringify(data.cards); 67 68 // svelte-ignore state_referenced_locally 69 let savedPronouns = $state(JSON.stringify(data.pronounsRecord)); 70 71 let hasUnsavedChanges = $state(false); 72 73 // Detect card content and publication changes (e.g. sidebar edits) 74 // The guard ensures JSON.stringify only runs while no changes are detected yet. 75 // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations 76 // but the early return makes it effectively free. 77 $effect(() => { 78 if (hasUnsavedChanges) return; 79 if ( 80 JSON.stringify(items) !== savedItemsSnapshot || 81 JSON.stringify(data.publication) !== publication || 82 JSON.stringify(data.pronounsRecord) !== savedPronouns 83 ) { 84 hasUnsavedChanges = true; 85 } 86 }); 87 88 // Warn user before closing tab if there are unsaved changes 89 $effect(() => { 90 function handleBeforeUnload(e: BeforeUnloadEvent) { 91 if (hasUnsavedChanges) { 92 e.preventDefault(); 93 return ''; 94 } 95 } 96 97 window.addEventListener('beforeunload', handleBeforeUnload); 98 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 99 }); 100 101 let gridContainer: HTMLDivElement | undefined = $state(); 102 103 let showingMobileView = $state(false); 104 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 105 let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024); 106 107 setIsMobile(() => isMobile); 108 109 // svelte-ignore state_referenced_locally 110 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 111 112 function onLayoutChanged() { 113 hasUnsavedChanges = true; 114 // Set the bit for the current layout: desktop=1, mobile=2 115 editedOn = editedOn | (isMobile ? 2 : 1); 116 if (shouldMirror(editedOn)) { 117 mirrorLayout(items, isMobile); 118 } 119 } 120 121 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 122 setIsCoarse(() => isCoarse); 123 124 let selectedCardId: string | null = $state(null); 125 let selectedCard = $derived( 126 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 127 ); 128 129 setSelectedCardId(() => selectedCardId); 130 setSelectCard((id: string | null) => { 131 selectedCardId = id; 132 }); 133 134 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 135 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 136 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 137 138 function newCard(type: string = 'link', cardData?: any) { 139 selectedCardId = null; 140 141 // close sidebar if open 142 const popover = document.getElementById('mobile-menu'); 143 if (popover) { 144 popover.hidePopover(); 145 } 146 147 let item = createEmptyCard(data.page); 148 item.cardType = type; 149 150 item.cardData = cardData ?? {}; 151 152 const cardDef = CardDefinitionsByType[type]; 153 cardDef?.createNew?.(item); 154 155 newItem.item = item; 156 157 if (cardDef?.creationModalComponent) { 158 newItem.modal = cardDef.creationModalComponent; 159 } else { 160 saveNewItem(); 161 } 162 } 163 164 function cleanupDialogArtifacts() { 165 // bits-ui's body scroll lock and portal may not clean up fully when the 166 // modal is unmounted instead of closed via the open prop. 167 const restore = () => { 168 document.body.style.removeProperty('overflow'); 169 document.body.style.removeProperty('pointer-events'); 170 document.body.style.removeProperty('padding-right'); 171 document.body.style.removeProperty('margin-right'); 172 // Remove any orphaned dialog overlay/content elements left by the portal 173 for (const el of document.querySelectorAll('[data-dialog-overlay], [data-dialog-content]')) { 174 el.remove(); 175 } 176 }; 177 // Run immediately and again after bits-ui's 24ms scheduled cleanup 178 restore(); 179 setTimeout(restore, 50); 180 } 181 182 async function saveNewItem() { 183 if (!newItem.item) return; 184 const item = newItem.item; 185 186 const viewportCenter = gridContainer 187 ? getViewportCenterGridY(gridContainer, isMobile) 188 : undefined; 189 setPositionOfNewItem(item, items, viewportCenter); 190 191 items = [...items, item]; 192 193 // Push overlapping items down, then compact to fill gaps 194 fixCollisions(items, item, false, true); 195 fixCollisions(items, item, true, true); 196 compactItems(items, false); 197 compactItems(items, true); 198 199 onLayoutChanged(); 200 201 newItem = {}; 202 203 await tick(); 204 cleanupDialogArtifacts(); 205 206 scrollToItem(item, isMobile, gridContainer); 207 } 208 209 let isSaving = $state(false); 210 let showSaveModal = $state(false); 211 let saveSuccess = $state(false); 212 213 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 214 215 async function save() { 216 isSaving = true; 217 saveSuccess = false; 218 showSaveModal = true; 219 220 try { 221 // Upload profile icon if changed 222 if (data.publication?.icon) { 223 await checkAndUploadImage(data.publication, 'icon'); 224 } 225 226 // Persist layout editing state 227 data.publication.preferences ??= {}; 228 data.publication.preferences.editedOn = editedOn; 229 230 await savePage(data, items, publication); 231 232 publication = JSON.stringify(data.publication); 233 savedPronouns = JSON.stringify(data.pronounsRecord); 234 235 savedItemsSnapshot = JSON.stringify(items); 236 hasUnsavedChanges = false; 237 238 saveSuccess = true; 239 240 launchConfetti(); 241 242 // Refresh cached data 243 await fetch('/' + data.handle + '/api/refresh'); 244 } catch (error) { 245 console.error(error); 246 showSaveModal = false; 247 toast.error('Error saving page!'); 248 } finally { 249 isSaving = false; 250 } 251 } 252 253 let linkValue = $state(''); 254 255 function addLink(url: string, specificCardDef?: CardDefinition) { 256 let link = validateLink(url); 257 if (!link) { 258 toast.error('invalid link'); 259 return; 260 } 261 let item = createEmptyCard(data.page); 262 263 if (specificCardDef?.onUrlHandler?.(link, item)) { 264 item.cardType = specificCardDef.type; 265 newItem.item = item; 266 saveNewItem(); 267 toast(specificCardDef.name + ' added!'); 268 return; 269 } 270 271 for (const cardDef of AllCardDefinitions.toSorted( 272 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 273 )) { 274 if (cardDef.onUrlHandler?.(link, item)) { 275 item.cardType = cardDef.type; 276 277 newItem.item = item; 278 saveNewItem(); 279 toast(cardDef.name + ' added!'); 280 break; 281 } 282 } 283 } 284 285 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 286 return new Promise((resolve) => { 287 const img = new Image(); 288 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 289 img.onerror = () => resolve({ width: 1, height: 1 }); 290 img.src = src; 291 }); 292 } 293 294 function getBestGridSize( 295 imageWidth: number, 296 imageHeight: number, 297 candidates: [number, number][] 298 ): [number, number] { 299 const imageRatio = imageWidth / imageHeight; 300 let best: [number, number] = candidates[0]; 301 let bestDiff = Infinity; 302 303 for (const candidate of candidates) { 304 const gridRatio = candidate[0] / candidate[1]; 305 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 306 if (diff < bestDiff) { 307 bestDiff = diff; 308 best = candidate; 309 } 310 } 311 312 return best; 313 } 314 315 const desktopSizeCandidates: [number, number][] = [ 316 [2, 2], 317 [2, 4], 318 [4, 2], 319 [4, 4], 320 [4, 6], 321 [6, 4] 322 ]; 323 const mobileSizeCandidates: [number, number][] = [ 324 [4, 4], 325 [4, 6], 326 [4, 8], 327 [6, 4], 328 [8, 4], 329 [8, 6] 330 ]; 331 332 async function processImageFile(file: File, gridX?: number, gridY?: number) { 333 const isGif = file.type === 'image/gif'; 334 335 // Don't compress GIFs to preserve animation 336 const objectUrl = URL.createObjectURL(file); 337 338 let item = createEmptyCard(data.page); 339 340 item.cardType = isGif ? 'gif' : 'image'; 341 item.cardData = { 342 image: { blob: file, objectUrl } 343 }; 344 345 // Size card based on image aspect ratio 346 const { width, height } = await getImageDimensions(objectUrl); 347 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 348 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 349 item.w = dw; 350 item.h = dh; 351 item.mobileW = mw; 352 item.mobileH = mh; 353 354 // If grid position is provided (image dropped on grid) 355 if (gridX !== undefined && gridY !== undefined) { 356 if (isMobile) { 357 item.mobileX = gridX; 358 item.mobileY = gridY; 359 // Derive desktop Y from mobile 360 item.x = Math.floor((COLUMNS - item.w) / 2); 361 item.x = Math.floor(item.x / 2) * 2; 362 item.y = Math.max(0, Math.round(gridY / 2)); 363 } else { 364 item.x = gridX; 365 item.y = gridY; 366 // Derive mobile Y from desktop 367 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 368 item.mobileX = Math.floor(item.mobileX / 2) * 2; 369 item.mobileY = Math.max(0, Math.round(gridY * 2)); 370 } 371 372 items = [...items, item]; 373 fixCollisions(items, item, isMobile); 374 fixCollisions(items, item, !isMobile); 375 } else { 376 const viewportCenter = gridContainer 377 ? getViewportCenterGridY(gridContainer, isMobile) 378 : undefined; 379 setPositionOfNewItem(item, items, viewportCenter); 380 items = [...items, item]; 381 fixCollisions(items, item, false, true); 382 fixCollisions(items, item, true, true); 383 compactItems(items, false); 384 compactItems(items, true); 385 } 386 387 onLayoutChanged(); 388 389 await tick(); 390 391 scrollToItem(item, isMobile, gridContainer); 392 } 393 394 async function handleFileDrop(files: File[], gridX: number, gridY: number) { 395 for (let i = 0; i < files.length; i++) { 396 // First image gets the drop position, rest use normal placement 397 if (i === 0) { 398 await processImageFile(files[i], gridX, gridY); 399 } else { 400 await processImageFile(files[i]); 401 } 402 } 403 } 404 405 async function handleImageInputChange(event: Event) { 406 const target = event.target as HTMLInputElement; 407 if (!target.files || target.files.length < 1) return; 408 409 const files = Array.from(target.files); 410 411 if (files.length === 1) { 412 // Single file: use default positioning 413 await processImageFile(files[0]); 414 } else { 415 // Multiple files: place in grid pattern starting from first available position 416 let gridX = 0; 417 let gridY = maxHeight; 418 const cardW = isMobile ? 4 : 2; 419 const cardH = isMobile ? 4 : 2; 420 421 for (const file of files) { 422 await processImageFile(file, gridX, gridY); 423 424 // Move to next cell position 425 gridX += cardW; 426 if (gridX + cardW > COLUMNS) { 427 gridX = 0; 428 gridY += cardH; 429 } 430 } 431 } 432 433 // Reset the input so the same file can be selected again 434 target.value = ''; 435 } 436 437 async function processVideoFile(file: File) { 438 const objectUrl = URL.createObjectURL(file); 439 440 let item = createEmptyCard(data.page); 441 442 item.cardType = 'video'; 443 item.cardData = { 444 blob: file, 445 objectUrl 446 }; 447 448 const viewportCenter = gridContainer 449 ? getViewportCenterGridY(gridContainer, isMobile) 450 : undefined; 451 setPositionOfNewItem(item, items, viewportCenter); 452 items = [...items, item]; 453 fixCollisions(items, item, false, true); 454 fixCollisions(items, item, true, true); 455 compactItems(items, false); 456 compactItems(items, true); 457 458 onLayoutChanged(); 459 460 await tick(); 461 462 scrollToItem(item, isMobile, gridContainer); 463 } 464 465 async function handleVideoInputChange(event: Event) { 466 const target = event.target as HTMLInputElement; 467 if (!target.files || target.files.length < 1) return; 468 469 const files = Array.from(target.files); 470 471 for (const file of files) { 472 await processVideoFile(file); 473 } 474 475 // Reset the input so the same file can be selected again 476 target.value = ''; 477 } 478 479 let showCardCommand = $state(false); 480</script> 481 482<svelte:body 483 onpaste={(event) => { 484 if (isTyping()) return; 485 486 const text = event.clipboardData?.getData('text/plain'); 487 const link = validateLink(text, false); 488 if (!link) return; 489 490 addLink(link); 491 }} 492/> 493 494<Head 495 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 496 title={getName(data)} 497 image={'/' + data.handle + '/og.png'} 498 accentColor={data.publication?.preferences?.accentColor} 499 baseColor={data.publication?.preferences?.baseColor} 500/> 501 502<Account {data} /> 503 504<Context {data} isEditing={true}> 505 <ImageViewerProvider /> 506 <CardCommand 507 bind:open={showCardCommand} 508 onselect={(cardDef: CardDefinition) => { 509 if (cardDef.type === 'image') { 510 const input = document.getElementById('image-input') as HTMLInputElement; 511 if (input) { 512 input.click(); 513 return; 514 } 515 } else if (cardDef.type === 'video') { 516 const input = document.getElementById('video-input') as HTMLInputElement; 517 if (input) { 518 input.click(); 519 return; 520 } 521 } else { 522 newCard(cardDef.type); 523 } 524 }} 525 onlink={(url, cardDef) => { 526 addLink(url, cardDef); 527 }} 528 /> 529 530 <Controls bind:data /> 531 532 {#if showingMobileView} 533 <div 534 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 535 ></div> 536 {/if} 537 538 {#if newItem.modal && newItem.item} 539 <newItem.modal 540 oncreate={() => { 541 saveNewItem(); 542 }} 543 bind:item={newItem.item} 544 oncancel={async () => { 545 newItem = {}; 546 await tick(); 547 cleanupDialogArtifacts(); 548 }} 549 /> 550 {/if} 551 552 <SaveModal 553 bind:open={showSaveModal} 554 success={saveSuccess} 555 handle={data.handle} 556 page={data.page} 557 /> 558 559 <Modal open={showMobileWarning} closeButton={false}> 560 <div class="flex flex-col items-center gap-4 text-center"> 561 <svg 562 xmlns="http://www.w3.org/2000/svg" 563 fill="none" 564 viewBox="0 0 24 24" 565 stroke-width="1.5" 566 stroke="currentColor" 567 class="text-accent-500 size-10" 568 > 569 <path 570 stroke-linecap="round" 571 stroke-linejoin="round" 572 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" 573 /> 574 </svg> 575 <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p> 576 <p class="text-base-500 dark:text-base-400 text-sm"> 577 Mobile editing is currently experimental. For the best experience, use a desktop browser. 578 </p> 579 <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button> 580 </div> 581 </Modal> 582 583 <div 584 class={[ 585 '@container/wrapper relative w-full', 586 showingMobileView 587 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 588 : '' 589 ]} 590 > 591 {#if !getHideProfileSection(data)} 592 <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> 593 {/if} 594 595 <div 596 class={[ 597 'pointer-events-none relative mx-auto max-w-lg', 598 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 599 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 600 : '@5xl/wrapper:max-w-4xl' 601 ]} 602 > 603 <div class="pointer-events-none"></div> 604 <EditableGrid 605 bind:items 606 bind:ref={gridContainer} 607 {isMobile} 608 {selectedCardId} 609 {isCoarse} 610 onlayoutchange={onLayoutChanged} 611 ondeselect={() => { 612 selectedCardId = null; 613 }} 614 onfiledrop={handleFileDrop} 615 > 616 {#each items as item, i (item.id)} 617 <BaseEditingCard 618 bind:item={items[i]} 619 ondelete={() => { 620 items = items.filter((it) => it !== item); 621 compactItems(items, false); 622 compactItems(items, true); 623 onLayoutChanged(); 624 }} 625 onsetsize={(newW: number, newH: number) => { 626 if (isMobile) { 627 item.mobileW = newW; 628 item.mobileH = newH; 629 } else { 630 item.w = newW; 631 item.h = newH; 632 } 633 634 fixCollisions(items, item, isMobile); 635 onLayoutChanged(); 636 }} 637 > 638 <EditingCard bind:item={items[i]} /> 639 </BaseEditingCard> 640 {/each} 641 </EditableGrid> 642 </div> 643 </div> 644 645 <EditBar 646 {data} 647 bind:linkValue 648 bind:isSaving 649 bind:showingMobileView 650 {hasUnsavedChanges} 651 {newCard} 652 {addLink} 653 {save} 654 {handleImageInputChange} 655 {handleVideoInputChange} 656 showCardCommand={() => { 657 showCardCommand = true; 658 }} 659 {selectedCard} 660 {isMobile} 661 {isCoarse} 662 ondeselect={() => { 663 selectedCardId = null; 664 }} 665 ondelete={() => { 666 if (selectedCard) { 667 items = items.filter((it) => it.id !== selectedCardId); 668 compactItems(items, false); 669 compactItems(items, true); 670 onLayoutChanged(); 671 selectedCardId = null; 672 } 673 }} 674 onsetsize={(w: number, h: number) => { 675 if (selectedCard) { 676 if (isMobile) { 677 selectedCard.mobileW = w; 678 selectedCard.mobileH = h; 679 } else { 680 selectedCard.w = w; 681 selectedCard.h = h; 682 } 683 fixCollisions(items, selectedCard, isMobile); 684 onLayoutChanged(); 685 } 686 }} 687 /> 688 689 <Toaster /> 690 691 <FloatingEditButton {data} /> 692 693 {#if dev} 694 <div 695 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" 696 > 697 <span>editedOn: {editedOn}</span> 698 </div> 699 {/if} 700</Context>