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) {
542 let link = validateLink(url);
543 if (!link) {
544 toast.error('invalid link');
545 return;
546 }
547 let item = createEmptyCard(data.page);
548
549 for (const cardDef of AllCardDefinitions.toSorted(
550 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
551 )) {
552 if (cardDef.onUrlHandler?.(link, item)) {
553 item.cardType = cardDef.type;
554
555 newItem.item = item;
556 saveNewItem();
557 toast(cardDef.name + ' added!');
558 break;
559 }
560 }
561
562 if (linkValue === url) {
563 linkValue = '';
564 }
565 }
566
567 function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
568 return new Promise((resolve) => {
569 const img = new Image();
570 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
571 img.onerror = () => resolve({ width: 1, height: 1 });
572 img.src = src;
573 });
574 }
575
576 function getBestGridSize(
577 imageWidth: number,
578 imageHeight: number,
579 candidates: [number, number][]
580 ): [number, number] {
581 const imageRatio = imageWidth / imageHeight;
582 let best: [number, number] = candidates[0];
583 let bestDiff = Infinity;
584
585 for (const candidate of candidates) {
586 const gridRatio = candidate[0] / candidate[1];
587 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio));
588 if (diff < bestDiff) {
589 bestDiff = diff;
590 best = candidate;
591 }
592 }
593
594 return best;
595 }
596
597 const desktopSizeCandidates: [number, number][] = [
598 [2, 2],
599 [2, 4],
600 [4, 2],
601 [4, 4],
602 [4, 6],
603 [6, 4]
604 ];
605 const mobileSizeCandidates: [number, number][] = [
606 [4, 4],
607 [4, 6],
608 [4, 8],
609 [6, 4],
610 [8, 4],
611 [8, 6]
612 ];
613
614 async function processImageFile(file: File, gridX?: number, gridY?: number) {
615 const isGif = file.type === 'image/gif';
616
617 // Don't compress GIFs to preserve animation
618 const objectUrl = URL.createObjectURL(file);
619
620 let item = createEmptyCard(data.page);
621
622 item.cardType = isGif ? 'gif' : 'image';
623 item.cardData = {
624 image: { blob: file, objectUrl }
625 };
626
627 // Size card based on image aspect ratio
628 const { width, height } = await getImageDimensions(objectUrl);
629 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates);
630 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates);
631 item.w = dw;
632 item.h = dh;
633 item.mobileW = mw;
634 item.mobileH = mh;
635
636 // If grid position is provided (image dropped on grid)
637 if (gridX !== undefined && gridY !== undefined) {
638 if (isMobile) {
639 item.mobileX = gridX;
640 item.mobileY = gridY;
641 // Derive desktop Y from mobile
642 item.x = Math.floor((COLUMNS - item.w) / 2);
643 item.x = Math.floor(item.x / 2) * 2;
644 item.y = Math.max(0, Math.round(gridY / 2));
645 } else {
646 item.x = gridX;
647 item.y = gridY;
648 // Derive mobile Y from desktop
649 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2);
650 item.mobileX = Math.floor(item.mobileX / 2) * 2;
651 item.mobileY = Math.max(0, Math.round(gridY * 2));
652 }
653
654 items = [...items, item];
655 fixCollisions(items, item, isMobile);
656 fixCollisions(items, item, !isMobile);
657 } else {
658 const viewportCenter = getViewportCenterGridY();
659 setPositionOfNewItem(item, items, viewportCenter);
660 items = [...items, item];
661 fixCollisions(items, item, false, true);
662 fixCollisions(items, item, true, true);
663 compactItems(items, false);
664 compactItems(items, true);
665 }
666
667 onLayoutChanged();
668
669 await tick();
670
671 scrollToItem(item, isMobile, container);
672 }
673
674 function handleImageDragOver(event: DragEvent) {
675 const dt = event.dataTransfer;
676 if (!dt) return;
677
678 let hasImage = false;
679 if (dt.items) {
680 for (let i = 0; i < dt.items.length; i++) {
681 const item = dt.items[i];
682 if (item && item.kind === 'file' && item.type.startsWith('image/')) {
683 hasImage = true;
684 break;
685 }
686 }
687 } else if (dt.files) {
688 for (let i = 0; i < dt.files.length; i++) {
689 const file = dt.files[i];
690 if (file?.type.startsWith('image/')) {
691 hasImage = true;
692 break;
693 }
694 }
695 }
696
697 if (hasImage) {
698 event.preventDefault();
699 event.stopPropagation();
700
701 imageDragOver = true;
702 }
703 }
704
705 function handleImageDragLeave(event: DragEvent) {
706 event.preventDefault();
707 event.stopPropagation();
708 imageDragOver = false;
709 }
710
711 async function handleImageDrop(event: DragEvent) {
712 event.preventDefault();
713 event.stopPropagation();
714 const dropX = event.clientX;
715 const dropY = event.clientY;
716 imageDragOver = false;
717
718 if (!event.dataTransfer?.files?.length) return;
719
720 const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
721 f?.type.startsWith('image/')
722 );
723 if (imageFiles.length === 0) return;
724
725 // Calculate starting grid position from drop coordinates
726 let gridX = 0;
727 let gridY = 0;
728 if (container) {
729 const rect = container.getBoundingClientRect();
730 const currentMargin = isMobile ? mobileMargin : margin;
731 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
732 const cardW = isMobile ? 4 : 2;
733
734 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
735 gridX = Math.floor(gridX / 2) * 2;
736
737 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0);
738 if (isMobile) {
739 gridY = Math.floor(gridY / 2) * 2;
740 }
741 }
742
743 for (let i = 0; i < imageFiles.length; i++) {
744 // First image gets the drop position, rest use normal placement
745 if (i === 0) {
746 await processImageFile(imageFiles[i], gridX, gridY);
747 } else {
748 await processImageFile(imageFiles[i]);
749 }
750 }
751 }
752
753 async function handleImageInputChange(event: Event) {
754 const target = event.target as HTMLInputElement;
755 if (!target.files || target.files.length < 1) return;
756
757 const files = Array.from(target.files);
758
759 if (files.length === 1) {
760 // Single file: use default positioning
761 await processImageFile(files[0]);
762 } else {
763 // Multiple files: place in grid pattern starting from first available position
764 let gridX = 0;
765 let gridY = maxHeight;
766 const cardW = isMobile ? 4 : 2;
767 const cardH = isMobile ? 4 : 2;
768
769 for (const file of files) {
770 await processImageFile(file, gridX, gridY);
771
772 // Move to next cell position
773 gridX += cardW;
774 if (gridX + cardW > COLUMNS) {
775 gridX = 0;
776 gridY += cardH;
777 }
778 }
779 }
780
781 // Reset the input so the same file can be selected again
782 target.value = '';
783 }
784
785 async function processVideoFile(file: File) {
786 const objectUrl = URL.createObjectURL(file);
787
788 let item = createEmptyCard(data.page);
789
790 item.cardType = 'video';
791 item.cardData = {
792 blob: file,
793 objectUrl
794 };
795
796 const viewportCenter = getViewportCenterGridY();
797 setPositionOfNewItem(item, items, viewportCenter);
798 items = [...items, item];
799 fixCollisions(items, item, false, true);
800 fixCollisions(items, item, true, true);
801 compactItems(items, false);
802 compactItems(items, true);
803
804 onLayoutChanged();
805
806 await tick();
807
808 scrollToItem(item, isMobile, container);
809 }
810
811 async function handleVideoInputChange(event: Event) {
812 const target = event.target as HTMLInputElement;
813 if (!target.files || target.files.length < 1) return;
814
815 const files = Array.from(target.files);
816
817 for (const file of files) {
818 await processVideoFile(file);
819 }
820
821 // Reset the input so the same file can be selected again
822 target.value = '';
823 }
824
825 let showCardCommand = $state(false);
826</script>
827
828<svelte:body
829 onpaste={(event) => {
830 if (isTyping()) return;
831
832 const text = event.clipboardData?.getData('text/plain');
833 const link = validateLink(text, false);
834 if (!link) return;
835
836 addLink(link);
837 }}
838/>
839
840<svelte:window
841 ondragover={handleImageDragOver}
842 ondragleave={handleImageDragLeave}
843 ondrop={handleImageDrop}
844/>
845
846<Head
847 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar}
848 title={getName(data)}
849 image={'/' + data.handle + '/og.png'}
850 accentColor={data.publication?.preferences?.accentColor}
851 baseColor={data.publication?.preferences?.baseColor}
852/>
853
854<Account {data} />
855
856<Context {data}>
857 {#if !dev}
858 <div
859 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"
860 >
861 Editing on mobile is not supported yet. Please use a desktop browser.
862 </div>
863 {/if}
864
865 <CardCommand
866 bind:open={showCardCommand}
867 onselect={(cardDef: CardDefinition) => {
868 if (cardDef.type === 'image') {
869 const input = document.getElementById('image-input') as HTMLInputElement;
870 if (input) {
871 input.click();
872 return;
873 }
874 } else {
875 newCard(cardDef.type);
876 }
877 }}
878 />
879
880 <Controls bind:data />
881
882 {#if showingMobileView}
883 <div
884 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full"
885 ></div>
886 {/if}
887
888 {#if newItem.modal && newItem.item}
889 <newItem.modal
890 oncreate={() => {
891 saveNewItem();
892 }}
893 bind:item={newItem.item}
894 oncancel={() => {
895 newItem = {};
896 }}
897 />
898 {/if}
899
900 <SaveModal
901 bind:open={showSaveModal}
902 success={saveSuccess}
903 handle={data.handle}
904 page={data.page}
905 />
906
907 <div
908 class={[
909 '@container/wrapper relative w-full',
910 showingMobileView
911 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90'
912 : ''
913 ]}
914 >
915 {#if !getHideProfileSection(data)}
916 <EditableProfile bind:data hideBlento={showLoginOnEditPage} />
917 {/if}
918
919 <div
920 class={[
921 'pointer-events-none relative mx-auto max-w-lg',
922 !getHideProfileSection(data) && getProfilePosition(data) === 'side'
923 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4'
924 : '@5xl/wrapper:max-w-4xl'
925 ]}
926 >
927 <div class="pointer-events-none"></div>
928 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
929 <!-- svelte-ignore a11y_click_events_have_key_events -->
930 <div
931 bind:this={container}
932 onclick={(e) => {
933 // Deselect when tapping empty grid space
934 if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) {
935 selectedCardId = null;
936 }
937 }}
938 ontouchstart={touchStart}
939 ontouchend={touchEnd}
940 ondragover={(e) => {
941 e.preventDefault();
942
943 const result = getDragXY(e);
944 if (!result) return;
945
946 activeDragElement.x = result.x;
947 activeDragElement.y = result.y;
948
949 if (activeDragElement.item) {
950 // Get dragged card's original position for swapping
951 const draggedOrigPos = activeDragElement.originalPositions.get(
952 activeDragElement.item.id
953 );
954
955 // Reset all items to original positions first
956 for (const it of items) {
957 const origPos = activeDragElement.originalPositions.get(it.id);
958 if (origPos && it !== activeDragElement.item) {
959 if (isMobile) {
960 it.mobileX = origPos.mobileX;
961 it.mobileY = origPos.mobileY;
962 } else {
963 it.x = origPos.x;
964 it.y = origPos.y;
965 }
966 }
967 }
968
969 // Update dragged item position
970 if (isMobile) {
971 activeDragElement.item.mobileX = result.x;
972 activeDragElement.item.mobileY = result.y;
973 } else {
974 activeDragElement.item.x = result.x;
975 activeDragElement.item.y = result.y;
976 }
977
978 // Handle horizontal swap
979 if (result.swapWithId && draggedOrigPos) {
980 const swapTarget = items.find((it) => it.id === result.swapWithId);
981 if (swapTarget) {
982 // Move swap target to dragged card's original position
983 if (isMobile) {
984 swapTarget.mobileX = draggedOrigPos.mobileX;
985 swapTarget.mobileY = draggedOrigPos.mobileY;
986 } else {
987 swapTarget.x = draggedOrigPos.x;
988 swapTarget.y = draggedOrigPos.y;
989 }
990 }
991 }
992
993 // Now fix collisions (with compacting)
994 fixCollisions(items, activeDragElement.item, isMobile);
995 }
996
997 // Auto-scroll when dragging near top or bottom of viewport
998 const scrollZone = 100;
999 const scrollSpeed = 10;
1000 const viewportHeight = window.innerHeight;
1001
1002 if (e.clientY < scrollZone) {
1003 // Near top - scroll up
1004 const intensity = 1 - e.clientY / scrollZone;
1005 window.scrollBy(0, -scrollSpeed * intensity);
1006 } else if (e.clientY > viewportHeight - scrollZone) {
1007 // Near bottom - scroll down
1008 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
1009 window.scrollBy(0, scrollSpeed * intensity);
1010 }
1011 }}
1012 ondragend={async (e) => {
1013 e.preventDefault();
1014 const cell = getDragXY(e);
1015 if (!cell) return;
1016
1017 if (activeDragElement.item) {
1018 if (isMobile) {
1019 activeDragElement.item.mobileX = cell.x;
1020 activeDragElement.item.mobileY = cell.y;
1021 } else {
1022 activeDragElement.item.x = cell.x;
1023 activeDragElement.item.y = cell.y;
1024 }
1025
1026 // Fix collisions and compact items after drag ends
1027 fixCollisions(items, activeDragElement.item, isMobile);
1028 onLayoutChanged();
1029 }
1030 activeDragElement.x = -1;
1031 activeDragElement.y = -1;
1032 activeDragElement.element = null;
1033 activeDragElement.item = null;
1034 activeDragElement.lastTargetId = null;
1035 activeDragElement.lastPlacement = null;
1036 return true;
1037 }}
1038 class={[
1039 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8',
1040 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed'
1041 ]}
1042 >
1043 {#each items as item, i (item.id)}
1044 <!-- {#if item !== activeDragElement.item} -->
1045 <BaseEditingCard
1046 bind:item={items[i]}
1047 ondelete={() => {
1048 items = items.filter((it) => it !== item);
1049 compactItems(items, false);
1050 compactItems(items, true);
1051 onLayoutChanged();
1052 }}
1053 onsetsize={(newW: number, newH: number) => {
1054 if (isMobile) {
1055 item.mobileW = newW;
1056 item.mobileH = newH;
1057 } else {
1058 item.w = newW;
1059 item.h = newH;
1060 }
1061
1062 fixCollisions(items, item, isMobile);
1063 onLayoutChanged();
1064 }}
1065 ondragstart={(e: DragEvent) => {
1066 const target = e.currentTarget as HTMLDivElement;
1067 activeDragElement.element = target;
1068 activeDragElement.w = item.w;
1069 activeDragElement.h = item.h;
1070 activeDragElement.item = item;
1071
1072 // Store original positions of all items
1073 activeDragElement.originalPositions = new Map();
1074 for (const it of items) {
1075 activeDragElement.originalPositions.set(it.id, {
1076 x: it.x,
1077 y: it.y,
1078 mobileX: it.mobileX,
1079 mobileY: it.mobileY
1080 });
1081 }
1082
1083 const rect = target.getBoundingClientRect();
1084 activeDragElement.mouseDeltaX = rect.left - e.clientX;
1085 activeDragElement.mouseDeltaY = rect.top - e.clientY;
1086 }}
1087 >
1088 <EditingCard bind:item={items[i]} />
1089 </BaseEditingCard>
1090 <!-- {/if} -->
1091 {/each}
1092
1093 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div>
1094 </div>
1095 </div>
1096 </div>
1097
1098 <EditBar
1099 {data}
1100 bind:linkValue
1101 bind:isSaving
1102 bind:showingMobileView
1103 {hasUnsavedChanges}
1104 {newCard}
1105 {addLink}
1106 {save}
1107 {handleImageInputChange}
1108 {handleVideoInputChange}
1109 showCardCommand={() => {
1110 showCardCommand = true;
1111 }}
1112 {selectedCard}
1113 {isMobile}
1114 {isCoarse}
1115 ondeselect={() => {
1116 selectedCardId = null;
1117 }}
1118 ondelete={() => {
1119 if (selectedCard) {
1120 items = items.filter((it) => it.id !== selectedCardId);
1121 compactItems(items, false);
1122 compactItems(items, true);
1123 onLayoutChanged();
1124 selectedCardId = null;
1125 }
1126 }}
1127 onsetsize={(w: number, h: number) => {
1128 if (selectedCard) {
1129 if (isMobile) {
1130 selectedCard.mobileW = w;
1131 selectedCard.mobileH = h;
1132 } else {
1133 selectedCard.w = w;
1134 selectedCard.h = h;
1135 }
1136 fixCollisions(items, selectedCard, isMobile);
1137 onLayoutChanged();
1138 }
1139 }}
1140 />
1141
1142 <Toaster />
1143
1144 <FloatingEditButton {data} />
1145
1146 {#if dev}
1147 <div class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs">
1148 editedOn: {editedOn}
1149 </div>
1150 {/if}
1151</Context>