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