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