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