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 image: { blob: processedFile, objectUrl }
324 };
325
326 // If grid position is provided
327 if (gridX !== undefined && gridY !== undefined) {
328 if (isMobile) {
329 item.mobileX = gridX;
330 item.mobileY = gridY;
331 } else {
332 item.x = gridX;
333 item.y = gridY;
334 }
335
336 items = [...items, item];
337 fixCollisions(items, item, isMobile);
338 } else {
339 setPositionOfNewItem(item, items);
340 items = [...items, item];
341 }
342
343 await tick();
344
345 scrollToItem(item, isMobile, container);
346 }
347
348 function handleImageDragOver(event: DragEvent) {
349 const dt = event.dataTransfer;
350 if (!dt) return;
351
352 let hasImage = false;
353 if (dt.items) {
354 for (let i = 0; i < dt.items.length; i++) {
355 const item = dt.items[i];
356 if (item && item.kind === 'file' && item.type.startsWith('image/')) {
357 hasImage = true;
358 break;
359 }
360 }
361 } else if (dt.files) {
362 for (let i = 0; i < dt.files.length; i++) {
363 const file = dt.files[i];
364 if (file?.type.startsWith('image/')) {
365 hasImage = true;
366 break;
367 }
368 }
369 }
370
371 if (hasImage) {
372 event.preventDefault();
373 event.stopPropagation();
374
375 imageDragOver = true;
376 }
377 }
378
379 function handleImageDragLeave(event: DragEvent) {
380 event.preventDefault();
381 event.stopPropagation();
382 imageDragOver = false;
383 }
384
385 async function handleImageDrop(event: DragEvent) {
386 event.preventDefault();
387 event.stopPropagation();
388 const dropX = event.clientX;
389 const dropY = event.clientY;
390 imageDragOver = false;
391
392 if (!event.dataTransfer?.files?.length) return;
393
394 const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
395 f?.type.startsWith('image/')
396 );
397 if (imageFiles.length === 0) return;
398
399 // Calculate starting grid position from drop coordinates
400 let gridX = 0;
401 let gridY = 0;
402 if (container) {
403 const rect = container.getBoundingClientRect();
404 const currentMargin = isMobile ? mobileMargin : margin;
405 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
406 const cardW = isMobile ? 4 : 2;
407
408 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
409 gridX = Math.floor(gridX / 2) * 2;
410
411 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0);
412 if (isMobile) {
413 gridY = Math.floor(gridY / 2) * 2;
414 }
415 }
416
417 for (const file of imageFiles) {
418 await processImageFile(file, gridX, gridY);
419
420 // Move to next cell position
421 const cardW = isMobile ? 4 : 2;
422 gridX += cardW;
423 if (gridX + cardW > COLUMNS) {
424 gridX = 0;
425 gridY += isMobile ? 4 : 2;
426 }
427 }
428 }
429
430 async function handleImageInputChange(event: Event) {
431 const target = event.target as HTMLInputElement;
432 if (!target.files || target.files.length < 1) return;
433
434 const files = Array.from(target.files);
435
436 if (files.length === 1) {
437 // Single file: use default positioning
438 await processImageFile(files[0]);
439 } else {
440 // Multiple files: place in grid pattern starting from first available position
441 let gridX = 0;
442 let gridY = maxHeight;
443 const cardW = isMobile ? 4 : 2;
444 const cardH = isMobile ? 4 : 2;
445
446 for (const file of files) {
447 await processImageFile(file, gridX, gridY);
448
449 // Move to next cell position
450 gridX += cardW;
451 if (gridX + cardW > COLUMNS) {
452 gridX = 0;
453 gridY += cardH;
454 }
455 }
456 }
457
458 // Reset the input so the same file can be selected again
459 target.value = '';
460 }
461
462 async function processVideoFile(file: File) {
463 const objectUrl = URL.createObjectURL(file);
464
465 let item = createEmptyCard(data.page);
466
467 item.cardType = 'video';
468 item.cardData = {
469 blob: file,
470 objectUrl
471 };
472
473 setPositionOfNewItem(item, items);
474 items = [...items, item];
475
476 await tick();
477
478 scrollToItem(item, isMobile, container);
479 }
480
481 async function handleVideoInputChange(event: Event) {
482 const target = event.target as HTMLInputElement;
483 if (!target.files || target.files.length < 1) return;
484
485 const files = Array.from(target.files);
486
487 for (const file of files) {
488 await processVideoFile(file);
489 }
490
491 // Reset the input so the same file can be selected again
492 target.value = '';
493 }
494
495 $inspect(items);
496</script>
497
498<svelte:body
499 onpaste={(event) => {
500 if (isTyping()) return;
501
502 const text = event.clipboardData?.getData('text/plain');
503 const link = validateLink(text, false);
504 if (!link) return;
505
506 addLink(link);
507 }}
508/>
509
510<svelte:window
511 ondragover={handleImageDragOver}
512 ondragleave={handleImageDragLeave}
513 ondrop={handleImageDrop}
514/>
515
516<Head
517 favicon={data.profile.avatar ?? null}
518 title={getName(data)}
519 image={'/' + data.handle + '/og.png'}
520/>
521
522<Settings bind:open={showSettings} bind:data />
523
524<Account {data} />
525
526<Context {data}>
527 {#if !dev}
528 <div
529 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"
530 >
531 Editing on mobile is not supported yet. Please use a desktop browser.
532 </div>
533 {/if}
534
535 {#if showingMobileView}
536 <div
537 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full"
538 ></div>
539 {/if}
540
541 {#if newItem.modal && newItem.item}
542 <newItem.modal
543 oncreate={() => {
544 saveNewItem();
545 }}
546 bind:item={newItem.item}
547 oncancel={() => {
548 newItem = {};
549 }}
550 />
551 {/if}
552
553 <div
554 class={[
555 '@container/wrapper relative w-full',
556 showingMobileView
557 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-[375px]'
558 : ''
559 ]}
560 >
561 {#if !getHideProfileSection(data)}
562 <Profile {data} />
563 {/if}
564
565 <div
566 class={[
567 'mx-auto max-w-lg',
568 !getHideProfileSection(data)
569 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4'
570 : '@5xl/wrapper:max-w-4xl'
571 ]}
572 >
573 <div></div>
574 <!-- svelte-ignore a11y_no_static_element_interactions -->
575 <div
576 bind:this={container}
577 ondragover={(e) => {
578 e.preventDefault();
579
580 const result = getDragXY(e);
581 if (!result) return;
582
583 activeDragElement.x = result.x;
584 activeDragElement.y = result.y;
585
586 if (activeDragElement.item) {
587 // Get dragged card's original position for swapping
588 const draggedOrigPos = activeDragElement.originalPositions.get(
589 activeDragElement.item.id
590 );
591
592 // Reset all items to original positions first
593 for (const it of items) {
594 const origPos = activeDragElement.originalPositions.get(it.id);
595 if (origPos && it !== activeDragElement.item) {
596 if (isMobile) {
597 it.mobileX = origPos.mobileX;
598 it.mobileY = origPos.mobileY;
599 } else {
600 it.x = origPos.x;
601 it.y = origPos.y;
602 }
603 }
604 }
605
606 // Update dragged item position
607 if (isMobile) {
608 activeDragElement.item.mobileX = result.x;
609 activeDragElement.item.mobileY = result.y;
610 } else {
611 activeDragElement.item.x = result.x;
612 activeDragElement.item.y = result.y;
613 }
614
615 // Handle horizontal swap
616 if (result.swapWithId && draggedOrigPos) {
617 const swapTarget = items.find((it) => it.id === result.swapWithId);
618 if (swapTarget) {
619 // Move swap target to dragged card's original position
620 if (isMobile) {
621 swapTarget.mobileX = draggedOrigPos.mobileX;
622 swapTarget.mobileY = draggedOrigPos.mobileY;
623 } else {
624 swapTarget.x = draggedOrigPos.x;
625 swapTarget.y = draggedOrigPos.y;
626 }
627 }
628 }
629
630 // Now fix collisions (with compacting)
631 fixCollisions(items, activeDragElement.item, isMobile);
632 }
633
634 // Auto-scroll when dragging near top or bottom of viewport
635 const scrollZone = 100;
636 const scrollSpeed = 10;
637 const viewportHeight = window.innerHeight;
638
639 if (e.clientY < scrollZone) {
640 // Near top - scroll up
641 const intensity = 1 - e.clientY / scrollZone;
642 window.scrollBy(0, -scrollSpeed * intensity);
643 } else if (e.clientY > viewportHeight - scrollZone) {
644 // Near bottom - scroll down
645 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
646 window.scrollBy(0, scrollSpeed * intensity);
647 }
648 }}
649 ondragend={async (e) => {
650 e.preventDefault();
651 const cell = getDragXY(e);
652 if (!cell) return;
653
654 if (activeDragElement.item) {
655 if (isMobile) {
656 activeDragElement.item.mobileX = cell.x;
657 activeDragElement.item.mobileY = cell.y;
658 } else {
659 activeDragElement.item.x = cell.x;
660 activeDragElement.item.y = cell.y;
661 }
662
663 // Fix collisions and compact items after drag ends
664 fixCollisions(items, activeDragElement.item, isMobile);
665 }
666 activeDragElement.x = -1;
667 activeDragElement.y = -1;
668 activeDragElement.element = null;
669 activeDragElement.item = null;
670 activeDragElement.lastTargetId = null;
671 activeDragElement.lastPlacement = null;
672 return true;
673 }}
674 class={[
675 '@container/grid relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8',
676 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed'
677 ]}
678 >
679 {#each items as item, i (item.id)}
680 <!-- {#if item !== activeDragElement.item} -->
681 <BaseEditingCard
682 bind:item={items[i]}
683 ondelete={() => {
684 items = items.filter((it) => it !== item);
685 compactItems(items, isMobile);
686 }}
687 onsetsize={(newW: number, newH: number) => {
688 if (isMobile) {
689 item.mobileW = newW;
690 item.mobileH = newH;
691 } else {
692 item.w = newW;
693 item.h = newH;
694 }
695
696 fixCollisions(items, item, isMobile);
697 }}
698 ondragstart={(e: DragEvent) => {
699 const target = e.currentTarget as HTMLDivElement;
700 activeDragElement.element = target;
701 activeDragElement.w = item.w;
702 activeDragElement.h = item.h;
703 activeDragElement.item = item;
704
705 // Store original positions of all items
706 activeDragElement.originalPositions = new Map();
707 for (const it of items) {
708 activeDragElement.originalPositions.set(it.id, {
709 x: it.x,
710 y: it.y,
711 mobileX: it.mobileX,
712 mobileY: it.mobileY
713 });
714 }
715
716 const rect = target.getBoundingClientRect();
717 activeDragElement.mouseDeltaX = rect.left - e.clientX;
718 activeDragElement.mouseDeltaY = rect.top - e.clientY;
719 }}
720 >
721 <EditingCard bind:item={items[i]} />
722 </BaseEditingCard>
723 <!-- {/if} -->
724 {/each}
725
726 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div>
727 </div>
728 </div>
729 </div>
730
731 <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4">
732 <div class="flex flex-col gap-2">
733 {#each sidebarItems as cardDef (cardDef.type)}
734 <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start"
735 >{cardDef.sidebarButtonText}</Button
736 >
737 {/each}
738 </div>
739 </Sidebar>
740
741 <EditBar
742 {data}
743 bind:linkValue
744 bind:isSaving
745 bind:showingMobileView
746 bind:showSettings
747 {newCard}
748 {addLink}
749 {save}
750 {handleImageInputChange}
751 {handleVideoInputChange}
752 />
753
754 <Toaster />
755</Context>