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 getImage
20 } from '../helper';
21 import EditableProfile from './EditableProfile.svelte';
22 import type { Item, WebsiteData } from '../types';
23 import { innerWidth } from 'svelte/reactivity/window';
24 import EditingCard from '../cards/Card/EditingCard.svelte';
25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards';
26 import { tick, type Component } from 'svelte';
27 import type { CardDefinition, CreationModalComponentProps } from '../cards/types';
28 import { dev } from '$app/environment';
29 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context';
30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte';
31 import Context from './Context.svelte';
32 import Head from './Head.svelte';
33 import Account from './Account.svelte';
34 import { SelectThemePopover } from '$lib/components/select-theme';
35 import EditBar from './EditBar.svelte';
36 import SaveModal from './SaveModal.svelte';
37 import FloatingEditButton from './FloatingEditButton.svelte';
38 import { user } from '$lib/atproto';
39 import { launchConfetti } from '@foxui/visual';
40 import Controls from './Controls.svelte';
41 import CardCommand from '$lib/components/card-command/CardCommand.svelte';
42 import { shouldMirror, mirrorLayout } from './layout-mirror';
43
44 let {
45 data
46 }: {
47 data: WebsiteData;
48 } = $props();
49
50 // Check if floating login button will be visible (to hide MadeWithBlento)
51 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn);
52
53 function updateTheme(newAccent: string, newBase: string) {
54 data.publication.preferences ??= {};
55 data.publication.preferences.accentColor = newAccent;
56 data.publication.preferences.baseColor = newBase;
57 data = { ...data };
58 }
59
60 let imageDragOver = $state(false);
61
62 // svelte-ignore state_referenced_locally
63 let items: Item[] = $state(data.cards);
64
65 // svelte-ignore state_referenced_locally
66 let publication = $state(JSON.stringify(data.publication));
67
68 // Track saved state for comparison
69 // svelte-ignore state_referenced_locally
70 let savedItems = $state(JSON.stringify(data.cards));
71 // svelte-ignore state_referenced_locally
72 let savedPublication = $state(JSON.stringify(data.publication));
73
74 let hasUnsavedChanges = $derived(
75 JSON.stringify(items) !== savedItems || JSON.stringify(data.publication) !== savedPublication
76 );
77
78 // Warn user before closing tab if there are unsaved changes
79 $effect(() => {
80 function handleBeforeUnload(e: BeforeUnloadEvent) {
81 if (hasUnsavedChanges) {
82 e.preventDefault();
83 return '';
84 }
85 }
86
87 window.addEventListener('beforeunload', handleBeforeUnload);
88 return () => window.removeEventListener('beforeunload', handleBeforeUnload);
89 });
90
91 let container: HTMLDivElement | undefined = $state();
92
93 let activeDragElement: {
94 element: HTMLDivElement | null;
95 item: Item | null;
96 w: number;
97 h: number;
98 x: number;
99 y: number;
100 mouseDeltaX: number;
101 mouseDeltaY: number;
102 // For hysteresis - track last decision to prevent flickering
103 lastTargetId: string | null;
104 lastPlacement: 'above' | 'below' | null;
105 // Store original positions to reset from during drag
106 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>;
107 } = $state({
108 element: null,
109 item: null,
110 w: 0,
111 h: 0,
112 x: -1,
113 y: -1,
114 mouseDeltaX: 0,
115 mouseDeltaY: 0,
116 lastTargetId: null,
117 lastPlacement: null,
118 originalPositions: new Map()
119 });
120
121 let showingMobileView = $state(false);
122 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024);
123
124 setIsMobile(() => isMobile);
125
126 // svelte-ignore state_referenced_locally
127 let editedOn = $state(data.publication.preferences?.editedOn ?? 0);
128
129 function onLayoutChanged() {
130 // Set the bit for the current layout: desktop=1, mobile=2
131 editedOn = editedOn | (isMobile ? 2 : 1);
132 if (shouldMirror(editedOn)) {
133 mirrorLayout(items, isMobile);
134 }
135 }
136
137 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
138 setIsCoarse(() => isCoarse);
139
140 let selectedCardId: string | null = $state(null);
141 let selectedCard = $derived(
142 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null
143 );
144
145 setSelectedCardId(() => selectedCardId);
146 setSelectCard((id: string | null) => {
147 selectedCardId = id;
148 });
149
150 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y);
151 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
152
153 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
154
155 function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined {
156 if (!container) return undefined;
157 const rect = container.getBoundingClientRect();
158 const currentMargin = isMobile ? mobileMargin : margin;
159 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
160 const viewportCenterY = window.innerHeight / 2;
161 const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize;
162 return { gridY, isMobile };
163 }
164
165 function newCard(type: string = 'link', cardData?: any) {
166 selectedCardId = null;
167
168 // close sidebar if open
169 const popover = document.getElementById('mobile-menu');
170 if (popover) {
171 popover.hidePopover();
172 }
173
174 let item = createEmptyCard(data.page);
175 item.cardType = type;
176
177 item.cardData = cardData ?? {};
178
179 const cardDef = CardDefinitionsByType[type];
180 cardDef?.createNew?.(item);
181
182 newItem.item = item;
183
184 if (cardDef?.creationModalComponent) {
185 newItem.modal = cardDef.creationModalComponent;
186 } else {
187 saveNewItem();
188 }
189 }
190
191 async function saveNewItem() {
192 if (!newItem.item) return;
193 const item = newItem.item;
194
195 const viewportCenter = getViewportCenterGridY();
196 setPositionOfNewItem(item, items, viewportCenter);
197
198 items = [...items, item];
199
200 // Push overlapping items down, then compact to fill gaps
201 fixCollisions(items, item, false, true);
202 fixCollisions(items, item, true, true);
203 compactItems(items, false);
204 compactItems(items, true);
205
206 onLayoutChanged();
207
208 newItem = {};
209
210 await tick();
211
212 scrollToItem(item, isMobile, container);
213 }
214
215 let isSaving = $state(false);
216 let showSaveModal = $state(false);
217 let saveSuccess = $state(false);
218
219 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({});
220
221 async function save() {
222 isSaving = true;
223 saveSuccess = false;
224 showSaveModal = true;
225
226 try {
227 // Upload profile icon if changed
228 if (data.publication?.icon) {
229 await checkAndUploadImage(data.publication, 'icon');
230 }
231
232 // Persist layout editing state
233 data.publication.preferences ??= {};
234 data.publication.preferences.editedOn = editedOn;
235
236 await savePage(data, items, publication);
237
238 publication = JSON.stringify(data.publication);
239
240 // Update saved state
241 savedItems = JSON.stringify(items);
242 savedPublication = JSON.stringify(data.publication);
243
244 saveSuccess = true;
245
246 launchConfetti();
247
248 // Refresh cached data
249 await fetch('/' + data.handle + '/api/refresh');
250 } catch (error) {
251 console.log(error);
252 showSaveModal = false;
253 toast.error('Error saving page!');
254 } finally {
255 isSaving = false;
256 }
257 }
258
259 const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText);
260
261 let debugPoint = $state({ x: 0, y: 0 });
262
263 function getGridPosition(
264 clientX: number,
265 clientY: number
266 ):
267 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null }
268 | undefined {
269 if (!container || !activeDragElement.item) return;
270
271 // x, y represent the top-left corner of the dragged card
272 const x = clientX + activeDragElement.mouseDeltaX;
273 const y = clientY + activeDragElement.mouseDeltaY;
274
275 const rect = container.getBoundingClientRect();
276 const currentMargin = isMobile ? mobileMargin : margin;
277 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
278
279 // Get card dimensions based on current view mode
280 const cardW = isMobile
281 ? (activeDragElement.item?.mobileW ?? activeDragElement.w)
282 : activeDragElement.w;
283 const cardH = isMobile
284 ? (activeDragElement.item?.mobileH ?? activeDragElement.h)
285 : activeDragElement.h;
286
287 // Get dragged card's original position
288 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
289
290 const draggedOrigY = draggedOrigPos
291 ? isMobile
292 ? draggedOrigPos.mobileY
293 : draggedOrigPos.y
294 : 0;
295
296 // Calculate raw grid position based on top-left of dragged card
297 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
298 gridX = Math.floor(gridX / 2) * 2;
299
300 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0);
301
302 if (isMobile) {
303 gridX = Math.floor(gridX / 2) * 2;
304 gridY = Math.floor(gridY / 2) * 2;
305 }
306
307 // Find if we're hovering over another card (using ORIGINAL positions)
308 const centerGridY = gridY + cardH / 2;
309 const centerGridX = gridX + cardW / 2;
310
311 let swapWithId: string | null = null;
312 let placement: 'above' | 'below' | null = null;
313
314 for (const other of items) {
315 if (other === activeDragElement.item) continue;
316
317 // Use original positions for hit testing
318 const origPos = activeDragElement.originalPositions.get(other.id);
319 if (!origPos) continue;
320
321 const otherX = isMobile ? origPos.mobileX : origPos.x;
322 const otherY = isMobile ? origPos.mobileY : origPos.y;
323 const otherW = isMobile ? other.mobileW : other.w;
324 const otherH = isMobile ? other.mobileH : other.h;
325
326 // Check if dragged card's center point is within this card's original bounds
327 if (
328 centerGridX >= otherX &&
329 centerGridX < otherX + otherW &&
330 centerGridY >= otherY &&
331 centerGridY < otherY + otherH
332 ) {
333 // Check if this is a swap situation:
334 // Cards have the same dimensions and are on the same row
335 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY;
336
337 if (canSwap) {
338 // Swap positions
339 swapWithId = other.id;
340 gridX = otherX;
341 gridY = otherY;
342 placement = null;
343
344 activeDragElement.lastTargetId = other.id;
345 activeDragElement.lastPlacement = null;
346 } else {
347 // Vertical placement (above/below)
348 // Detect drag direction: if dragging up, always place above
349 const isDraggingUp = gridY < draggedOrigY;
350
351 if (isDraggingUp) {
352 // When dragging up, always place above
353 placement = 'above';
354 } else {
355 // When dragging down, use top/bottom half logic
356 const midpointY = otherY + otherH / 2;
357 const hysteresis = 0.3;
358
359 if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) {
360 if (activeDragElement.lastPlacement === 'above') {
361 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above';
362 } else {
363 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below';
364 }
365 } else {
366 placement = centerGridY < midpointY ? 'above' : 'below';
367 }
368 }
369
370 activeDragElement.lastTargetId = other.id;
371 activeDragElement.lastPlacement = placement;
372
373 if (placement === 'above') {
374 gridY = otherY;
375 } else {
376 gridY = otherY + otherH;
377 }
378 }
379 break;
380 }
381 }
382
383 // If we're not over any card, clear the tracking
384 if (!swapWithId && !placement) {
385 activeDragElement.lastTargetId = null;
386 activeDragElement.lastPlacement = null;
387 }
388
389 debugPoint.x = x - rect.left;
390 debugPoint.y = y - rect.top + currentMargin;
391
392 return { x: gridX, y: gridY, swapWithId, placement };
393 }
394
395 function getDragXY(
396 e: DragEvent & {
397 currentTarget: EventTarget & HTMLDivElement;
398 }
399 ) {
400 return getGridPosition(e.clientX, e.clientY);
401 }
402
403 // Touch drag system (instant drag on selected card)
404 let touchDragActive = $state(false);
405
406 function touchStart(e: TouchEvent) {
407 if (!selectedCardId || !container) return;
408 const touch = e.touches[0];
409 if (!touch) return;
410
411 // Check if the touch is on the selected card element
412 const target = (e.target as HTMLElement)?.closest?.('.card');
413 if (!target || target.id !== selectedCardId) return;
414
415 const item = items.find((i) => i.id === selectedCardId);
416 if (!item || item.cardData?.locked) return;
417
418 // Start dragging immediately
419 touchDragActive = true;
420
421 const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement;
422 if (!cardEl) return;
423
424 activeDragElement.element = cardEl;
425 activeDragElement.w = item.w;
426 activeDragElement.h = item.h;
427 activeDragElement.item = item;
428
429 // Store original positions of all items
430 activeDragElement.originalPositions = new Map();
431 for (const it of items) {
432 activeDragElement.originalPositions.set(it.id, {
433 x: it.x,
434 y: it.y,
435 mobileX: it.mobileX,
436 mobileY: it.mobileY
437 });
438 }
439
440 const rect = cardEl.getBoundingClientRect();
441 activeDragElement.mouseDeltaX = rect.left - touch.clientX;
442 activeDragElement.mouseDeltaY = rect.top - touch.clientY;
443 }
444
445 function touchMove(e: TouchEvent) {
446 if (!touchDragActive) return;
447
448 const touch = e.touches[0];
449 if (!touch) return;
450
451 e.preventDefault();
452
453 const result = getGridPosition(touch.clientX, touch.clientY);
454 if (!result || !activeDragElement.item) return;
455
456 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
457
458 // Reset all items to original positions first
459 for (const it of items) {
460 const origPos = activeDragElement.originalPositions.get(it.id);
461 if (origPos && it !== activeDragElement.item) {
462 if (isMobile) {
463 it.mobileX = origPos.mobileX;
464 it.mobileY = origPos.mobileY;
465 } else {
466 it.x = origPos.x;
467 it.y = origPos.y;
468 }
469 }
470 }
471
472 // Update dragged item position
473 if (isMobile) {
474 activeDragElement.item.mobileX = result.x;
475 activeDragElement.item.mobileY = result.y;
476 } else {
477 activeDragElement.item.x = result.x;
478 activeDragElement.item.y = result.y;
479 }
480
481 // Handle horizontal swap
482 if (result.swapWithId && draggedOrigPos) {
483 const swapTarget = items.find((it) => it.id === result.swapWithId);
484 if (swapTarget) {
485 if (isMobile) {
486 swapTarget.mobileX = draggedOrigPos.mobileX;
487 swapTarget.mobileY = draggedOrigPos.mobileY;
488 } else {
489 swapTarget.x = draggedOrigPos.x;
490 swapTarget.y = draggedOrigPos.y;
491 }
492 }
493 }
494
495 fixCollisions(items, activeDragElement.item, isMobile);
496
497 // Auto-scroll near edges
498 const scrollZone = 100;
499 const scrollSpeed = 10;
500 const viewportHeight = window.innerHeight;
501
502 if (touch.clientY < scrollZone) {
503 const intensity = 1 - touch.clientY / scrollZone;
504 window.scrollBy(0, -scrollSpeed * intensity);
505 } else if (touch.clientY > viewportHeight - scrollZone) {
506 const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone;
507 window.scrollBy(0, scrollSpeed * intensity);
508 }
509 }
510
511 function touchEnd() {
512 if (touchDragActive && activeDragElement.item) {
513 // Finalize position
514 fixCollisions(items, activeDragElement.item, isMobile);
515 onLayoutChanged();
516
517 activeDragElement.x = -1;
518 activeDragElement.y = -1;
519 activeDragElement.element = null;
520 activeDragElement.item = null;
521 activeDragElement.lastTargetId = null;
522 activeDragElement.lastPlacement = null;
523 }
524
525 touchDragActive = false;
526 }
527
528 // Only register non-passive touchmove when actively dragging
529 $effect(() => {
530 const el = container;
531 if (!touchDragActive || !el) return;
532
533 el.addEventListener('touchmove', touchMove, { passive: false });
534 return () => {
535 el.removeEventListener('touchmove', touchMove);
536 };
537 });
538
539 let linkValue = $state('');
540
541 function addLink(url: string, specificCardDef?: CardDefinition) {
542 let link = validateLink(url);
543 if (!link) {
544 toast.error('invalid link');
545 return;
546 }
547 let item = createEmptyCard(data.page);
548
549 if (specificCardDef?.onUrlHandler?.(link, item)) {
550 item.cardType = specificCardDef.type;
551 newItem.item = item;
552 saveNewItem();
553 toast(specificCardDef.name + ' added!');
554 return;
555 }
556
557 for (const cardDef of AllCardDefinitions.toSorted(
558 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
559 )) {
560 if (cardDef.onUrlHandler?.(link, item)) {
561 item.cardType = cardDef.type;
562
563 newItem.item = item;
564 saveNewItem();
565 toast(cardDef.name + ' added!');
566 break;
567 }
568 }
569 }
570
571 function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
572 return new Promise((resolve) => {
573 const img = new Image();
574 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
575 img.onerror = () => resolve({ width: 1, height: 1 });
576 img.src = src;
577 });
578 }
579
580 function getBestGridSize(
581 imageWidth: number,
582 imageHeight: number,
583 candidates: [number, number][]
584 ): [number, number] {
585 const imageRatio = imageWidth / imageHeight;
586 let best: [number, number] = candidates[0];
587 let bestDiff = Infinity;
588
589 for (const candidate of candidates) {
590 const gridRatio = candidate[0] / candidate[1];
591 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio));
592 if (diff < bestDiff) {
593 bestDiff = diff;
594 best = candidate;
595 }
596 }
597
598 return best;
599 }
600
601 const desktopSizeCandidates: [number, number][] = [
602 [2, 2],
603 [2, 4],
604 [4, 2],
605 [4, 4],
606 [4, 6],
607 [6, 4]
608 ];
609 const mobileSizeCandidates: [number, number][] = [
610 [4, 4],
611 [4, 6],
612 [4, 8],
613 [6, 4],
614 [8, 4],
615 [8, 6]
616 ];
617
618 async function processImageFile(file: File, gridX?: number, gridY?: number) {
619 const isGif = file.type === 'image/gif';
620
621 // Don't compress GIFs to preserve animation
622 const objectUrl = URL.createObjectURL(file);
623
624 let item = createEmptyCard(data.page);
625
626 item.cardType = isGif ? 'gif' : 'image';
627 item.cardData = {
628 image: { blob: file, objectUrl }
629 };
630
631 // Size card based on image aspect ratio
632 const { width, height } = await getImageDimensions(objectUrl);
633 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates);
634 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates);
635 item.w = dw;
636 item.h = dh;
637 item.mobileW = mw;
638 item.mobileH = mh;
639
640 // If grid position is provided (image dropped on grid)
641 if (gridX !== undefined && gridY !== undefined) {
642 if (isMobile) {
643 item.mobileX = gridX;
644 item.mobileY = gridY;
645 // Derive desktop Y from mobile
646 item.x = Math.floor((COLUMNS - item.w) / 2);
647 item.x = Math.floor(item.x / 2) * 2;
648 item.y = Math.max(0, Math.round(gridY / 2));
649 } else {
650 item.x = gridX;
651 item.y = gridY;
652 // Derive mobile Y from desktop
653 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2);
654 item.mobileX = Math.floor(item.mobileX / 2) * 2;
655 item.mobileY = Math.max(0, Math.round(gridY * 2));
656 }
657
658 items = [...items, item];
659 fixCollisions(items, item, isMobile);
660 fixCollisions(items, item, !isMobile);
661 } else {
662 const viewportCenter = getViewportCenterGridY();
663 setPositionOfNewItem(item, items, viewportCenter);
664 items = [...items, item];
665 fixCollisions(items, item, false, true);
666 fixCollisions(items, item, true, true);
667 compactItems(items, false);
668 compactItems(items, true);
669 }
670
671 onLayoutChanged();
672
673 await tick();
674
675 scrollToItem(item, isMobile, container);
676 }
677
678 function handleImageDragOver(event: DragEvent) {
679 const dt = event.dataTransfer;
680 if (!dt) return;
681
682 let hasImage = false;
683 if (dt.items) {
684 for (let i = 0; i < dt.items.length; i++) {
685 const item = dt.items[i];
686 if (item && item.kind === 'file' && item.type.startsWith('image/')) {
687 hasImage = true;
688 break;
689 }
690 }
691 } else if (dt.files) {
692 for (let i = 0; i < dt.files.length; i++) {
693 const file = dt.files[i];
694 if (file?.type.startsWith('image/')) {
695 hasImage = true;
696 break;
697 }
698 }
699 }
700
701 if (hasImage) {
702 event.preventDefault();
703 event.stopPropagation();
704
705 imageDragOver = true;
706 }
707 }
708
709 function handleImageDragLeave(event: DragEvent) {
710 event.preventDefault();
711 event.stopPropagation();
712 imageDragOver = false;
713 }
714
715 async function handleImageDrop(event: DragEvent) {
716 event.preventDefault();
717 event.stopPropagation();
718 const dropX = event.clientX;
719 const dropY = event.clientY;
720 imageDragOver = false;
721
722 if (!event.dataTransfer?.files?.length) return;
723
724 const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
725 f?.type.startsWith('image/')
726 );
727 if (imageFiles.length === 0) return;
728
729 // Calculate starting grid position from drop coordinates
730 let gridX = 0;
731 let gridY = 0;
732 if (container) {
733 const rect = container.getBoundingClientRect();
734 const currentMargin = isMobile ? mobileMargin : margin;
735 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
736 const cardW = isMobile ? 4 : 2;
737
738 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
739 gridX = Math.floor(gridX / 2) * 2;
740
741 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0);
742 if (isMobile) {
743 gridY = Math.floor(gridY / 2) * 2;
744 }
745 }
746
747 for (let i = 0; i < imageFiles.length; i++) {
748 // First image gets the drop position, rest use normal placement
749 if (i === 0) {
750 await processImageFile(imageFiles[i], gridX, gridY);
751 } else {
752 await processImageFile(imageFiles[i]);
753 }
754 }
755 }
756
757 async function handleImageInputChange(event: Event) {
758 const target = event.target as HTMLInputElement;
759 if (!target.files || target.files.length < 1) return;
760
761 const files = Array.from(target.files);
762
763 if (files.length === 1) {
764 // Single file: use default positioning
765 await processImageFile(files[0]);
766 } else {
767 // Multiple files: place in grid pattern starting from first available position
768 let gridX = 0;
769 let gridY = maxHeight;
770 const cardW = isMobile ? 4 : 2;
771 const cardH = isMobile ? 4 : 2;
772
773 for (const file of files) {
774 await processImageFile(file, gridX, gridY);
775
776 // Move to next cell position
777 gridX += cardW;
778 if (gridX + cardW > COLUMNS) {
779 gridX = 0;
780 gridY += cardH;
781 }
782 }
783 }
784
785 // Reset the input so the same file can be selected again
786 target.value = '';
787 }
788
789 async function processVideoFile(file: File) {
790 const objectUrl = URL.createObjectURL(file);
791
792 let item = createEmptyCard(data.page);
793
794 item.cardType = 'video';
795 item.cardData = {
796 blob: file,
797 objectUrl
798 };
799
800 const viewportCenter = getViewportCenterGridY();
801 setPositionOfNewItem(item, items, viewportCenter);
802 items = [...items, item];
803 fixCollisions(items, item, false, true);
804 fixCollisions(items, item, true, true);
805 compactItems(items, false);
806 compactItems(items, true);
807
808 onLayoutChanged();
809
810 await tick();
811
812 scrollToItem(item, isMobile, container);
813 }
814
815 async function handleVideoInputChange(event: Event) {
816 const target = event.target as HTMLInputElement;
817 if (!target.files || target.files.length < 1) return;
818
819 const files = Array.from(target.files);
820
821 for (const file of files) {
822 await processVideoFile(file);
823 }
824
825 // Reset the input so the same file can be selected again
826 target.value = '';
827 }
828
829 let showCardCommand = $state(false);
830</script>
831
832<svelte:body
833 onpaste={(event) => {
834 if (isTyping()) return;
835
836 const text = event.clipboardData?.getData('text/plain');
837 const link = validateLink(text, false);
838 if (!link) return;
839
840 addLink(link);
841 }}
842/>
843
844<svelte:window
845 ondragover={handleImageDragOver}
846 ondragleave={handleImageDragLeave}
847 ondrop={handleImageDrop}
848/>
849
850<Head
851 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar}
852 title={getName(data)}
853 image={'/' + data.handle + '/og.png'}
854 accentColor={data.publication?.preferences?.accentColor}
855 baseColor={data.publication?.preferences?.baseColor}
856/>
857
858<Account {data} />
859
860<Context {data}>
861 {#if !dev}
862 <div
863 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"
864 >
865 Editing on mobile is not supported yet. Please use a desktop browser.
866 </div>
867 {/if}
868
869 <CardCommand
870 bind:open={showCardCommand}
871 onselect={(cardDef: CardDefinition) => {
872 if (cardDef.type === 'image') {
873 const input = document.getElementById('image-input') as HTMLInputElement;
874 if (input) {
875 input.click();
876 return;
877 }
878 } else if (cardDef.type === 'video') {
879 const input = document.getElementById('video-input') as HTMLInputElement;
880 if (input) {
881 input.click();
882 return;
883 }
884 } else {
885 newCard(cardDef.type);
886 }
887 }}
888 onlink={(url, cardDef) => {
889 addLink(url, cardDef);
890 }}
891 />
892
893 <Controls bind:data />
894
895 {#if showingMobileView}
896 <div
897 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full"
898 ></div>
899 {/if}
900
901 {#if newItem.modal && newItem.item}
902 <newItem.modal
903 oncreate={() => {
904 saveNewItem();
905 }}
906 bind:item={newItem.item}
907 oncancel={() => {
908 newItem = {};
909 }}
910 />
911 {/if}
912
913 <SaveModal
914 bind:open={showSaveModal}
915 success={saveSuccess}
916 handle={data.handle}
917 page={data.page}
918 />
919
920 <div
921 class={[
922 '@container/wrapper relative w-full',
923 showingMobileView
924 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90'
925 : ''
926 ]}
927 >
928 {#if !getHideProfileSection(data)}
929 <EditableProfile bind:data hideBlento={showLoginOnEditPage} />
930 {/if}
931
932 <div
933 class={[
934 'pointer-events-none relative mx-auto max-w-lg',
935 !getHideProfileSection(data) && getProfilePosition(data) === 'side'
936 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4'
937 : '@5xl/wrapper:max-w-4xl'
938 ]}
939 >
940 <div class="pointer-events-none"></div>
941 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
942 <!-- svelte-ignore a11y_click_events_have_key_events -->
943 <div
944 bind:this={container}
945 onclick={(e) => {
946 // Deselect when tapping empty grid space
947 if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) {
948 selectedCardId = null;
949 }
950 }}
951 ontouchstart={touchStart}
952 ontouchend={touchEnd}
953 ondragover={(e) => {
954 e.preventDefault();
955
956 const result = getDragXY(e);
957 if (!result) return;
958
959 activeDragElement.x = result.x;
960 activeDragElement.y = result.y;
961
962 if (activeDragElement.item) {
963 // Get dragged card's original position for swapping
964 const draggedOrigPos = activeDragElement.originalPositions.get(
965 activeDragElement.item.id
966 );
967
968 // Reset all items to original positions first
969 for (const it of items) {
970 const origPos = activeDragElement.originalPositions.get(it.id);
971 if (origPos && it !== activeDragElement.item) {
972 if (isMobile) {
973 it.mobileX = origPos.mobileX;
974 it.mobileY = origPos.mobileY;
975 } else {
976 it.x = origPos.x;
977 it.y = origPos.y;
978 }
979 }
980 }
981
982 // Update dragged item position
983 if (isMobile) {
984 activeDragElement.item.mobileX = result.x;
985 activeDragElement.item.mobileY = result.y;
986 } else {
987 activeDragElement.item.x = result.x;
988 activeDragElement.item.y = result.y;
989 }
990
991 // Handle horizontal swap
992 if (result.swapWithId && draggedOrigPos) {
993 const swapTarget = items.find((it) => it.id === result.swapWithId);
994 if (swapTarget) {
995 // Move swap target to dragged card's original position
996 if (isMobile) {
997 swapTarget.mobileX = draggedOrigPos.mobileX;
998 swapTarget.mobileY = draggedOrigPos.mobileY;
999 } else {
1000 swapTarget.x = draggedOrigPos.x;
1001 swapTarget.y = draggedOrigPos.y;
1002 }
1003 }
1004 }
1005
1006 // Now fix collisions (with compacting)
1007 fixCollisions(items, activeDragElement.item, isMobile);
1008 }
1009
1010 // Auto-scroll when dragging near top or bottom of viewport
1011 const scrollZone = 100;
1012 const scrollSpeed = 10;
1013 const viewportHeight = window.innerHeight;
1014
1015 if (e.clientY < scrollZone) {
1016 // Near top - scroll up
1017 const intensity = 1 - e.clientY / scrollZone;
1018 window.scrollBy(0, -scrollSpeed * intensity);
1019 } else if (e.clientY > viewportHeight - scrollZone) {
1020 // Near bottom - scroll down
1021 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
1022 window.scrollBy(0, scrollSpeed * intensity);
1023 }
1024 }}
1025 ondragend={async (e) => {
1026 e.preventDefault();
1027 // safari fix
1028 activeDragElement.x = -1;
1029 activeDragElement.y = -1;
1030 activeDragElement.element = null;
1031 activeDragElement.item = null;
1032 activeDragElement.lastTargetId = null;
1033 activeDragElement.lastPlacement = null;
1034 return true;
1035 }}
1036 class={[
1037 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8',
1038 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed'
1039 ]}
1040 >
1041 {#each items as item, i (item.id)}
1042 <!-- {#if item !== activeDragElement.item} -->
1043 <BaseEditingCard
1044 bind:item={items[i]}
1045 ondelete={() => {
1046 items = items.filter((it) => it !== item);
1047 compactItems(items, false);
1048 compactItems(items, true);
1049 onLayoutChanged();
1050 }}
1051 onsetsize={(newW: number, newH: number) => {
1052 if (isMobile) {
1053 item.mobileW = newW;
1054 item.mobileH = newH;
1055 } else {
1056 item.w = newW;
1057 item.h = newH;
1058 }
1059
1060 fixCollisions(items, item, isMobile);
1061 onLayoutChanged();
1062 }}
1063 ondragstart={(e: DragEvent) => {
1064 const target = e.currentTarget as HTMLDivElement;
1065 activeDragElement.element = target;
1066 activeDragElement.w = item.w;
1067 activeDragElement.h = item.h;
1068 activeDragElement.item = item;
1069 // fix for div shadow during drag and drop
1070 const transparent = document.createElement('div');
1071 transparent.style.position = 'fixed';
1072 transparent.style.top = '-1000px';
1073 transparent.style.width = '1px';
1074 transparent.style.height = '1px';
1075 document.body.appendChild(transparent);
1076 e.dataTransfer?.setDragImage(transparent, 0, 0);
1077 requestAnimationFrame(() => transparent.remove());
1078
1079 // Store original positions of all items
1080 activeDragElement.originalPositions = new Map();
1081 for (const it of items) {
1082 activeDragElement.originalPositions.set(it.id, {
1083 x: it.x,
1084 y: it.y,
1085 mobileX: it.mobileX,
1086 mobileY: it.mobileY
1087 });
1088 }
1089
1090 const rect = target.getBoundingClientRect();
1091 activeDragElement.mouseDeltaX = rect.left - e.clientX;
1092 activeDragElement.mouseDeltaY = rect.top - e.clientY;
1093 }}
1094 >
1095 <EditingCard bind:item={items[i]} />
1096 </BaseEditingCard>
1097 <!-- {/if} -->
1098 {/each}
1099
1100 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div>
1101 </div>
1102 </div>
1103 </div>
1104
1105 <EditBar
1106 {data}
1107 bind:linkValue
1108 bind:isSaving
1109 bind:showingMobileView
1110 {hasUnsavedChanges}
1111 {newCard}
1112 {addLink}
1113 {save}
1114 {handleImageInputChange}
1115 {handleVideoInputChange}
1116 showCardCommand={() => {
1117 showCardCommand = true;
1118 }}
1119 {selectedCard}
1120 {isMobile}
1121 {isCoarse}
1122 ondeselect={() => {
1123 selectedCardId = null;
1124 }}
1125 ondelete={() => {
1126 if (selectedCard) {
1127 items = items.filter((it) => it.id !== selectedCardId);
1128 compactItems(items, false);
1129 compactItems(items, true);
1130 onLayoutChanged();
1131 selectedCardId = null;
1132 }
1133 }}
1134 onsetsize={(w: number, h: number) => {
1135 if (selectedCard) {
1136 if (isMobile) {
1137 selectedCard.mobileW = w;
1138 selectedCard.mobileH = h;
1139 } else {
1140 selectedCard.w = w;
1141 selectedCard.h = h;
1142 }
1143 fixCollisions(items, selectedCard, isMobile);
1144 onLayoutChanged();
1145 }
1146 }}
1147 />
1148
1149 <Toaster />
1150
1151 <FloatingEditButton {data} />
1152
1153 {#if dev}
1154 <div
1155 class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs"
1156 >
1157 editedOn: {editedOn}
1158 </div>
1159 {/if}
1160</Context>