your personal website on atproto - mirror blento.app
at drawing-card 795 lines 21 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 fixCollisions, 11 getHideProfileSection, 12 getProfilePosition, 13 getName, 14 isTyping, 15 savePage, 16 scrollToItem, 17 setPositionOfNewItem, 18 validateLink 19 } from '../helper'; 20 import EditableProfile from './EditableProfile.svelte'; 21 import type { Item, WebsiteData } from '../types'; 22 import { innerWidth } from 'svelte/reactivity/window'; 23 import EditingCard from '../cards/Card/EditingCard.svelte'; 24 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 25 import { tick, type Component } from 'svelte'; 26 import type { CreationModalComponentProps } from '../cards/types'; 27 import { dev } from '$app/environment'; 28 import { setIsMobile } from './context'; 29 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 30 import Context from './Context.svelte'; 31 import Head from './Head.svelte'; 32 import { compressImage } from '../helper'; 33 import Account from './Account.svelte'; 34 import EditBar from './EditBar.svelte'; 35 import { user } from '$lib/atproto'; 36 37 let { 38 data 39 }: { 40 data: WebsiteData; 41 } = $props(); 42 43 let imageDragOver = $state(false); 44 45 // svelte-ignore state_referenced_locally 46 let items: Item[] = $state(data.cards); 47 48 // svelte-ignore state_referenced_locally 49 let publication = $state(JSON.stringify(data.publication)); 50 51 let container: HTMLDivElement | undefined = $state(); 52 53 let activeDragElement: { 54 element: HTMLDivElement | null; 55 item: Item | null; 56 w: number; 57 h: number; 58 x: number; 59 y: number; 60 mouseDeltaX: number; 61 mouseDeltaY: number; 62 // For hysteresis - track last decision to prevent flickering 63 lastTargetId: string | null; 64 lastPlacement: 'above' | 'below' | null; 65 // Store original positions to reset from during drag 66 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 67 } = $state({ 68 element: null, 69 item: null, 70 w: 0, 71 h: 0, 72 x: -1, 73 y: -1, 74 mouseDeltaX: 0, 75 mouseDeltaY: 0, 76 lastTargetId: null, 77 lastPlacement: null, 78 originalPositions: new Map() 79 }); 80 81 let showingMobileView = $state(false); 82 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 83 84 setIsMobile(() => isMobile); 85 86 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 87 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 88 89 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 90 91 function newCard(type: string = 'link', cardData?: any) { 92 // close sidebar if open 93 const popover = document.getElementById('mobile-menu'); 94 if (popover) { 95 popover.hidePopover(); 96 } 97 98 let item = createEmptyCard(data.page); 99 item.cardType = type; 100 101 item.cardData = cardData ?? {}; 102 103 const cardDef = CardDefinitionsByType[type]; 104 cardDef?.createNew?.(item); 105 106 newItem.item = item; 107 108 if (cardDef?.creationModalComponent) { 109 newItem.modal = cardDef.creationModalComponent; 110 } else { 111 saveNewItem(); 112 } 113 } 114 115 async function saveNewItem() { 116 if (!newItem.item) return; 117 const item = newItem.item; 118 119 setPositionOfNewItem(item, items); 120 121 items = [...items, item]; 122 123 newItem = {}; 124 125 await tick(); 126 127 scrollToItem(item, isMobile, container); 128 } 129 130 let isSaving = $state(false); 131 132 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 133 134 async function save() { 135 isSaving = true; 136 137 try { 138 // Upload profile icon if changed 139 if (data.publication?.icon) { 140 await checkAndUploadImage(data.publication, 'icon'); 141 } 142 143 await savePage(data, items, publication); 144 145 publication = JSON.stringify(data.publication); 146 } catch (error) { 147 console.log(error); 148 toast.error('Error saving page!'); 149 } finally { 150 isSaving = false; 151 } 152 } 153 154 const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText); 155 156 let debugPoint = $state({ x: 0, y: 0 }); 157 158 function getDragXY( 159 e: DragEvent & { 160 currentTarget: EventTarget & HTMLDivElement; 161 } 162 ): 163 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 164 | undefined { 165 if (!container || !activeDragElement.item) return; 166 167 // x, y represent the top-left corner of the dragged card 168 const x = e.clientX + activeDragElement.mouseDeltaX; 169 const y = e.clientY + activeDragElement.mouseDeltaY; 170 171 const rect = container.getBoundingClientRect(); 172 const currentMargin = isMobile ? mobileMargin : margin; 173 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 174 175 // Get card dimensions based on current view mode 176 const cardW = isMobile 177 ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 178 : activeDragElement.w; 179 const cardH = isMobile 180 ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 181 : activeDragElement.h; 182 183 // Get dragged card's original position 184 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 185 186 const draggedOrigY = draggedOrigPos 187 ? isMobile 188 ? draggedOrigPos.mobileY 189 : draggedOrigPos.y 190 : 0; 191 192 // Calculate raw grid position based on top-left of dragged card 193 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 194 gridX = Math.floor(gridX / 2) * 2; 195 196 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 197 198 if (isMobile) { 199 gridX = Math.floor(gridX / 2) * 2; 200 gridY = Math.floor(gridY / 2) * 2; 201 } 202 203 // Find if we're hovering over another card (using ORIGINAL positions) 204 const centerGridY = gridY + cardH / 2; 205 const centerGridX = gridX + cardW / 2; 206 207 let swapWithId: string | null = null; 208 let placement: 'above' | 'below' | null = null; 209 210 for (const other of items) { 211 if (other === activeDragElement.item) continue; 212 213 // Use original positions for hit testing 214 const origPos = activeDragElement.originalPositions.get(other.id); 215 if (!origPos) continue; 216 217 const otherX = isMobile ? origPos.mobileX : origPos.x; 218 const otherY = isMobile ? origPos.mobileY : origPos.y; 219 const otherW = isMobile ? other.mobileW : other.w; 220 const otherH = isMobile ? other.mobileH : other.h; 221 222 // Check if dragged card's center point is within this card's original bounds 223 if ( 224 centerGridX >= otherX && 225 centerGridX < otherX + otherW && 226 centerGridY >= otherY && 227 centerGridY < otherY + otherH 228 ) { 229 // Check if this is a swap situation: 230 // Cards have the same dimensions and are on the same row 231 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 232 233 if (canSwap) { 234 // Swap positions 235 swapWithId = other.id; 236 gridX = otherX; 237 gridY = otherY; 238 placement = null; 239 240 activeDragElement.lastTargetId = other.id; 241 activeDragElement.lastPlacement = null; 242 } else { 243 // Vertical placement (above/below) 244 // Detect drag direction: if dragging up, always place above 245 const isDraggingUp = gridY < draggedOrigY; 246 247 if (isDraggingUp) { 248 // When dragging up, always place above 249 placement = 'above'; 250 } else { 251 // When dragging down, use top/bottom half logic 252 const midpointY = otherY + otherH / 2; 253 const hysteresis = 0.3; 254 255 if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 256 if (activeDragElement.lastPlacement === 'above') { 257 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 258 } else { 259 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 260 } 261 } else { 262 placement = centerGridY < midpointY ? 'above' : 'below'; 263 } 264 } 265 266 activeDragElement.lastTargetId = other.id; 267 activeDragElement.lastPlacement = placement; 268 269 if (placement === 'above') { 270 gridY = otherY; 271 } else { 272 gridY = otherY + otherH; 273 } 274 } 275 break; 276 } 277 } 278 279 // If we're not over any card, clear the tracking 280 if (!swapWithId && !placement) { 281 activeDragElement.lastTargetId = null; 282 activeDragElement.lastPlacement = null; 283 } 284 285 debugPoint.x = x - rect.left; 286 debugPoint.y = y - rect.top + currentMargin; 287 288 return { x: gridX, y: gridY, swapWithId, placement }; 289 } 290 291 let linkValue = $state(''); 292 293 function addLink(url: string) { 294 let link = validateLink(url); 295 if (!link) { 296 toast.error('invalid link'); 297 return; 298 } 299 let item = createEmptyCard(data.page); 300 301 for (const cardDef of AllCardDefinitions.toSorted( 302 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 303 )) { 304 if (cardDef.onUrlHandler?.(link, item)) { 305 item.cardType = cardDef.type; 306 307 newItem.item = item; 308 saveNewItem(); 309 toast(cardDef.name + ' added!'); 310 break; 311 } 312 } 313 314 if (linkValue === url) { 315 linkValue = ''; 316 } 317 } 318 319 async function processImageFile(file: File, gridX?: number, gridY?: number) { 320 const isGif = file.type === 'image/gif'; 321 322 // Don't compress GIFs to preserve animation 323 const processedFile = isGif ? file : await compressImage(file); 324 const objectUrl = URL.createObjectURL(processedFile); 325 326 let item = createEmptyCard(data.page); 327 328 item.cardType = isGif ? 'gif' : 'image'; 329 item.cardData = { 330 image: { blob: processedFile, objectUrl } 331 }; 332 333 // If grid position is provided 334 if (gridX !== undefined && gridY !== undefined) { 335 if (isMobile) { 336 item.mobileX = gridX; 337 item.mobileY = gridY; 338 // Find valid desktop position 339 findValidPosition(item, items, false); 340 } else { 341 item.x = gridX; 342 item.y = gridY; 343 // Find valid mobile position 344 findValidPosition(item, items, true); 345 } 346 347 items = [...items, item]; 348 fixCollisions(items, item, isMobile); 349 } else { 350 setPositionOfNewItem(item, items); 351 items = [...items, item]; 352 } 353 354 await tick(); 355 356 scrollToItem(item, isMobile, container); 357 } 358 359 function handleImageDragOver(event: DragEvent) { 360 const dt = event.dataTransfer; 361 if (!dt) return; 362 363 let hasImage = false; 364 if (dt.items) { 365 for (let i = 0; i < dt.items.length; i++) { 366 const item = dt.items[i]; 367 if (item && item.kind === 'file' && item.type.startsWith('image/')) { 368 hasImage = true; 369 break; 370 } 371 } 372 } else if (dt.files) { 373 for (let i = 0; i < dt.files.length; i++) { 374 const file = dt.files[i]; 375 if (file?.type.startsWith('image/')) { 376 hasImage = true; 377 break; 378 } 379 } 380 } 381 382 if (hasImage) { 383 event.preventDefault(); 384 event.stopPropagation(); 385 386 imageDragOver = true; 387 } 388 } 389 390 function handleImageDragLeave(event: DragEvent) { 391 event.preventDefault(); 392 event.stopPropagation(); 393 imageDragOver = false; 394 } 395 396 async function handleImageDrop(event: DragEvent) { 397 event.preventDefault(); 398 event.stopPropagation(); 399 const dropX = event.clientX; 400 const dropY = event.clientY; 401 imageDragOver = false; 402 403 if (!event.dataTransfer?.files?.length) return; 404 405 const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 406 f?.type.startsWith('image/') 407 ); 408 if (imageFiles.length === 0) return; 409 410 // Calculate starting grid position from drop coordinates 411 let gridX = 0; 412 let gridY = 0; 413 if (container) { 414 const rect = container.getBoundingClientRect(); 415 const currentMargin = isMobile ? mobileMargin : margin; 416 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 417 const cardW = isMobile ? 4 : 2; 418 419 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 420 gridX = Math.floor(gridX / 2) * 2; 421 422 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 423 if (isMobile) { 424 gridY = Math.floor(gridY / 2) * 2; 425 } 426 } 427 428 for (const file of imageFiles) { 429 await processImageFile(file, gridX, gridY); 430 431 // Move to next cell position 432 const cardW = isMobile ? 4 : 2; 433 gridX += cardW; 434 if (gridX + cardW > COLUMNS) { 435 gridX = 0; 436 gridY += isMobile ? 4 : 2; 437 } 438 } 439 } 440 441 async function handleImageInputChange(event: Event) { 442 const target = event.target as HTMLInputElement; 443 if (!target.files || target.files.length < 1) return; 444 445 const files = Array.from(target.files); 446 447 if (files.length === 1) { 448 // Single file: use default positioning 449 await processImageFile(files[0]); 450 } else { 451 // Multiple files: place in grid pattern starting from first available position 452 let gridX = 0; 453 let gridY = maxHeight; 454 const cardW = isMobile ? 4 : 2; 455 const cardH = isMobile ? 4 : 2; 456 457 for (const file of files) { 458 await processImageFile(file, gridX, gridY); 459 460 // Move to next cell position 461 gridX += cardW; 462 if (gridX + cardW > COLUMNS) { 463 gridX = 0; 464 gridY += cardH; 465 } 466 } 467 } 468 469 // Reset the input so the same file can be selected again 470 target.value = ''; 471 } 472 473 async function processVideoFile(file: File) { 474 const objectUrl = URL.createObjectURL(file); 475 476 let item = createEmptyCard(data.page); 477 478 item.cardType = 'video'; 479 item.cardData = { 480 blob: file, 481 objectUrl 482 }; 483 484 setPositionOfNewItem(item, items); 485 items = [...items, item]; 486 487 await tick(); 488 489 scrollToItem(item, isMobile, container); 490 } 491 492 async function handleVideoInputChange(event: Event) { 493 const target = event.target as HTMLInputElement; 494 if (!target.files || target.files.length < 1) return; 495 496 const files = Array.from(target.files); 497 498 for (const file of files) { 499 await processVideoFile(file); 500 } 501 502 // Reset the input so the same file can be selected again 503 target.value = ''; 504 } 505 506 // $inspect(items); 507</script> 508 509<svelte:body 510 onpaste={(event) => { 511 if (isTyping()) return; 512 513 const text = event.clipboardData?.getData('text/plain'); 514 const link = validateLink(text, false); 515 if (!link) return; 516 517 addLink(link); 518 }} 519/> 520 521<svelte:window 522 ondragover={handleImageDragOver} 523 ondragleave={handleImageDragLeave} 524 ondrop={handleImageDrop} 525/> 526 527<Head 528 favicon={data.profile.avatar ?? null} 529 title={getName(data)} 530 image={'/' + data.handle + '/og.png'} 531/> 532 533<Account {data} /> 534 535<Context {data}> 536 {#if !dev} 537 <div 538 class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 539 > 540 Editing on mobile is not supported yet. Please use a desktop browser. 541 </div> 542 {/if} 543 544 {#if showingMobileView} 545 <div 546 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 547 ></div> 548 {/if} 549 550 {#if newItem.modal && newItem.item} 551 <newItem.modal 552 oncreate={() => { 553 saveNewItem(); 554 }} 555 bind:item={newItem.item} 556 oncancel={() => { 557 newItem = {}; 558 }} 559 /> 560 {/if} 561 562 <div 563 class={[ 564 '@container/wrapper relative w-full', 565 showingMobileView 566 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 567 : '' 568 ]} 569 > 570 {#if !getHideProfileSection(data)} 571 <EditableProfile bind:data /> 572 {/if} 573 574 <div 575 class={[ 576 'pointer-events-none relative mx-auto max-w-lg', 577 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 578 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 579 : '@5xl/wrapper:max-w-4xl' 580 ]} 581 > 582 {#if getHideProfileSection(data)} 583 <Button 584 size="icon" 585 variant="ghost" 586 onclick={() => { 587 data.publication.preferences ??= {}; 588 data.publication.preferences.hideProfileSection = false; 589 data = { ...data }; 590 }} 591 class="pointer-events-auto absolute top-2 left-2 z-20" 592 > 593 <svg 594 xmlns="http://www.w3.org/2000/svg" 595 fill="none" 596 viewBox="0 0 24 24" 597 stroke-width="1.5" 598 stroke="currentColor" 599 class="size-6" 600 > 601 <path 602 stroke-linecap="round" 603 stroke-linejoin="round" 604 d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" 605 /> 606 <path 607 stroke-linecap="round" 608 stroke-linejoin="round" 609 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 610 /> 611 </svg> 612 </Button> 613 {/if} 614 <div class="pointer-events-none"></div> 615 <!-- svelte-ignore a11y_no_static_element_interactions --> 616 <div 617 bind:this={container} 618 ondragover={(e) => { 619 e.preventDefault(); 620 621 const result = getDragXY(e); 622 if (!result) return; 623 624 activeDragElement.x = result.x; 625 activeDragElement.y = result.y; 626 627 if (activeDragElement.item) { 628 // Get dragged card's original position for swapping 629 const draggedOrigPos = activeDragElement.originalPositions.get( 630 activeDragElement.item.id 631 ); 632 633 // Reset all items to original positions first 634 for (const it of items) { 635 const origPos = activeDragElement.originalPositions.get(it.id); 636 if (origPos && it !== activeDragElement.item) { 637 if (isMobile) { 638 it.mobileX = origPos.mobileX; 639 it.mobileY = origPos.mobileY; 640 } else { 641 it.x = origPos.x; 642 it.y = origPos.y; 643 } 644 } 645 } 646 647 // Update dragged item position 648 if (isMobile) { 649 activeDragElement.item.mobileX = result.x; 650 activeDragElement.item.mobileY = result.y; 651 } else { 652 activeDragElement.item.x = result.x; 653 activeDragElement.item.y = result.y; 654 } 655 656 // Handle horizontal swap 657 if (result.swapWithId && draggedOrigPos) { 658 const swapTarget = items.find((it) => it.id === result.swapWithId); 659 if (swapTarget) { 660 // Move swap target to dragged card's original position 661 if (isMobile) { 662 swapTarget.mobileX = draggedOrigPos.mobileX; 663 swapTarget.mobileY = draggedOrigPos.mobileY; 664 } else { 665 swapTarget.x = draggedOrigPos.x; 666 swapTarget.y = draggedOrigPos.y; 667 } 668 } 669 } 670 671 // Now fix collisions (with compacting) 672 fixCollisions(items, activeDragElement.item, isMobile); 673 } 674 675 // Auto-scroll when dragging near top or bottom of viewport 676 const scrollZone = 100; 677 const scrollSpeed = 10; 678 const viewportHeight = window.innerHeight; 679 680 if (e.clientY < scrollZone) { 681 // Near top - scroll up 682 const intensity = 1 - e.clientY / scrollZone; 683 window.scrollBy(0, -scrollSpeed * intensity); 684 } else if (e.clientY > viewportHeight - scrollZone) { 685 // Near bottom - scroll down 686 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 687 window.scrollBy(0, scrollSpeed * intensity); 688 } 689 }} 690 ondragend={async (e) => { 691 e.preventDefault(); 692 const cell = getDragXY(e); 693 if (!cell) return; 694 695 if (activeDragElement.item) { 696 if (isMobile) { 697 activeDragElement.item.mobileX = cell.x; 698 activeDragElement.item.mobileY = cell.y; 699 } else { 700 activeDragElement.item.x = cell.x; 701 activeDragElement.item.y = cell.y; 702 } 703 704 // Fix collisions and compact items after drag ends 705 fixCollisions(items, activeDragElement.item, isMobile); 706 } 707 activeDragElement.x = -1; 708 activeDragElement.y = -1; 709 activeDragElement.element = null; 710 activeDragElement.item = null; 711 activeDragElement.lastTargetId = null; 712 activeDragElement.lastPlacement = null; 713 return true; 714 }} 715 class={[ 716 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 717 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 718 ]} 719 > 720 {#each items as item, i (item.id)} 721 <!-- {#if item !== activeDragElement.item} --> 722 <BaseEditingCard 723 bind:item={items[i]} 724 ondelete={() => { 725 items = items.filter((it) => it !== item); 726 compactItems(items, isMobile); 727 }} 728 onsetsize={(newW: number, newH: number) => { 729 if (isMobile) { 730 item.mobileW = newW; 731 item.mobileH = newH; 732 } else { 733 item.w = newW; 734 item.h = newH; 735 } 736 737 fixCollisions(items, item, isMobile); 738 }} 739 ondragstart={(e: DragEvent) => { 740 const target = e.currentTarget as HTMLDivElement; 741 activeDragElement.element = target; 742 activeDragElement.w = item.w; 743 activeDragElement.h = item.h; 744 activeDragElement.item = item; 745 746 // Store original positions of all items 747 activeDragElement.originalPositions = new Map(); 748 for (const it of items) { 749 activeDragElement.originalPositions.set(it.id, { 750 x: it.x, 751 y: it.y, 752 mobileX: it.mobileX, 753 mobileY: it.mobileY 754 }); 755 } 756 757 const rect = target.getBoundingClientRect(); 758 activeDragElement.mouseDeltaX = rect.left - e.clientX; 759 activeDragElement.mouseDeltaY = rect.top - e.clientY; 760 }} 761 > 762 <EditingCard bind:item={items[i]} /> 763 </BaseEditingCard> 764 <!-- {/if} --> 765 {/each} 766 767 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 768 </div> 769 </div> 770 </div> 771 772 <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 773 <div class="flex flex-col gap-2"> 774 {#each sidebarItems as cardDef (cardDef.type)} 775 <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 776 >{cardDef.sidebarButtonText}</Button 777 > 778 {/each} 779 </div> 780 </Sidebar> 781 782 <EditBar 783 {data} 784 bind:linkValue 785 bind:isSaving 786 bind:showingMobileView 787 {newCard} 788 {addLink} 789 {save} 790 {handleImageInputChange} 791 {handleVideoInputChange} 792 /> 793 794 <Toaster /> 795</Context>