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