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