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