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