your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

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