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