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