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