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