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