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 fixAllCollisions,
11 fixCollisions,
12 getHideProfileSection,
13 getProfilePosition,
14 getName,
15 isTyping,
16 savePage,
17 scrollToItem,
18 setPositionOfNewItem,
19 validateLink,
20 getImage
21 } from '../helper';
22 import EditableProfile from './EditableProfile.svelte';
23 import type { Item, WebsiteData } from '../types';
24 import { innerWidth } from 'svelte/reactivity/window';
25 import EditingCard from '../cards/Card/EditingCard.svelte';
26 import { AllCardDefinitions, CardDefinitionsByType } from '../cards';
27 import { tick, type Component } from 'svelte';
28 import type { CardDefinition, CreationModalComponentProps } from '../cards/types';
29 import { dev } from '$app/environment';
30 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context';
31 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte';
32 import Context from './Context.svelte';
33 import Head from './Head.svelte';
34 import Account from './Account.svelte';
35 import { SelectThemePopover } from '$lib/components/select-theme';
36 import EditBar from './EditBar.svelte';
37 import SaveModal from './SaveModal.svelte';
38 import FloatingEditButton from './FloatingEditButton.svelte';
39 import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto';
40 import * as TID from '@atcute/tid';
41 import { launchConfetti } from '@foxui/visual';
42 import Controls from './Controls.svelte';
43 import CardCommand from '$lib/components/card-command/CardCommand.svelte';
44 import { shouldMirror, mirrorLayout } from './layout-mirror';
45 import { SvelteMap } from 'svelte/reactivity';
46
47 let {
48 data
49 }: {
50 data: WebsiteData;
51 } = $props();
52
53 // Check if floating login button will be visible (to hide MadeWithBlento)
54 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn);
55
56 function updateTheme(newAccent: string, newBase: string) {
57 data.publication.preferences ??= {};
58 data.publication.preferences.accentColor = newAccent;
59 data.publication.preferences.baseColor = newBase;
60 data = { ...data };
61 }
62
63 let imageDragOver = $state(false);
64
65 // svelte-ignore state_referenced_locally
66 let items: Item[] = $state(data.cards);
67
68 // svelte-ignore state_referenced_locally
69 let publication = $state(JSON.stringify(data.publication));
70
71 // Track saved state for comparison
72 // svelte-ignore state_referenced_locally
73 let savedItems = $state(JSON.stringify(data.cards));
74 // svelte-ignore state_referenced_locally
75 let savedPublication = $state(JSON.stringify(data.publication));
76
77 let hasUnsavedChanges = $derived(
78 JSON.stringify(items) !== savedItems || JSON.stringify(data.publication) !== savedPublication
79 );
80
81 // Warn user before closing tab if there are unsaved changes
82 $effect(() => {
83 function handleBeforeUnload(e: BeforeUnloadEvent) {
84 if (hasUnsavedChanges) {
85 e.preventDefault();
86 return '';
87 }
88 }
89
90 window.addEventListener('beforeunload', handleBeforeUnload);
91 return () => window.removeEventListener('beforeunload', handleBeforeUnload);
92 });
93
94 let container: HTMLDivElement | undefined = $state();
95
96 let activeDragElement: {
97 element: HTMLDivElement | null;
98 item: Item | null;
99 w: number;
100 h: number;
101 x: number;
102 y: number;
103 mouseDeltaX: number;
104 mouseDeltaY: number;
105 // For hysteresis - track last decision to prevent flickering
106 lastTargetId: string | null;
107 lastPlacement: 'above' | 'below' | null;
108 // Store original positions to reset from during drag
109 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>;
110 } = $state({
111 element: null,
112 item: null,
113 w: 0,
114 h: 0,
115 x: -1,
116 y: -1,
117 mouseDeltaX: 0,
118 mouseDeltaY: 0,
119 lastTargetId: null,
120 lastPlacement: null,
121 originalPositions: new Map()
122 });
123
124 let showingMobileView = $state(false);
125 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024);
126
127 setIsMobile(() => isMobile);
128
129 // svelte-ignore state_referenced_locally
130 let editedOn = $state(data.publication.preferences?.editedOn ?? 0);
131
132 function onLayoutChanged() {
133 // Set the bit for the current layout: desktop=1, mobile=2
134 editedOn = editedOn | (isMobile ? 2 : 1);
135 if (shouldMirror(editedOn)) {
136 mirrorLayout(items, isMobile);
137 }
138 }
139
140 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
141 setIsCoarse(() => isCoarse);
142
143 let selectedCardId: string | null = $state(null);
144 let selectedCard = $derived(
145 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null
146 );
147
148 setSelectedCardId(() => selectedCardId);
149 setSelectCard((id: string | null) => {
150 selectedCardId = id;
151 });
152
153 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y);
154 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
155
156 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
157
158 function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined {
159 if (!container) return undefined;
160 const rect = container.getBoundingClientRect();
161 const currentMargin = isMobile ? mobileMargin : margin;
162 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
163 const viewportCenterY = window.innerHeight / 2;
164 const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize;
165 return { gridY, isMobile };
166 }
167
168 function newCard(type: string = 'link', cardData?: any) {
169 selectedCardId = null;
170
171 // close sidebar if open
172 const popover = document.getElementById('mobile-menu');
173 if (popover) {
174 popover.hidePopover();
175 }
176
177 let item = createEmptyCard(data.page);
178 item.cardType = type;
179
180 item.cardData = cardData ?? {};
181
182 const cardDef = CardDefinitionsByType[type];
183 cardDef?.createNew?.(item);
184
185 newItem.item = item;
186
187 if (cardDef?.creationModalComponent) {
188 newItem.modal = cardDef.creationModalComponent;
189 } else {
190 saveNewItem();
191 }
192 }
193
194 async function saveNewItem() {
195 if (!newItem.item) return;
196 const item = newItem.item;
197
198 const viewportCenter = getViewportCenterGridY();
199 setPositionOfNewItem(item, items, viewportCenter);
200
201 items = [...items, item];
202
203 // Push overlapping items down, then compact to fill gaps
204 fixCollisions(items, item, false, true);
205 fixCollisions(items, item, true, true);
206 compactItems(items, false);
207 compactItems(items, true);
208
209 onLayoutChanged();
210
211 newItem = {};
212
213 await tick();
214
215 scrollToItem(item, isMobile, container);
216 }
217
218 let isSaving = $state(false);
219 let showSaveModal = $state(false);
220 let saveSuccess = $state(false);
221
222 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({});
223
224 async function save() {
225 isSaving = true;
226 saveSuccess = false;
227 showSaveModal = true;
228
229 try {
230 // Upload profile icon if changed
231 if (data.publication?.icon) {
232 await checkAndUploadImage(data.publication, 'icon');
233 }
234
235 // Persist layout editing state
236 data.publication.preferences ??= {};
237 data.publication.preferences.editedOn = editedOn;
238
239 await savePage(data, items, publication);
240
241 publication = JSON.stringify(data.publication);
242
243 // Update saved state
244 savedItems = JSON.stringify(items);
245 savedPublication = JSON.stringify(data.publication);
246
247 saveSuccess = true;
248
249 launchConfetti();
250
251 // Refresh cached data
252 await fetch('/' + data.handle + '/api/refresh');
253 } catch (error) {
254 console.log(error);
255 showSaveModal = false;
256 toast.error('Error saving page!');
257 } finally {
258 isSaving = false;
259 }
260 }
261
262 const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.name);
263
264 function addAllCardTypes() {
265 const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games'];
266 const grouped = new SvelteMap<string, CardDefinition[]>();
267
268 for (const def of AllCardDefinitions) {
269 if (!def.name) continue;
270 const group = def.groups?.[0] ?? 'Other';
271 if (!grouped.has(group)) grouped.set(group, []);
272 grouped.get(group)!.push(def);
273 }
274
275 // Sort groups by predefined order, unknowns at end
276 const sortedGroups = [...grouped.keys()].sort((a, b) => {
277 const ai = groupOrder.indexOf(a);
278 const bi = groupOrder.indexOf(b);
279 return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
280 });
281
282 // Sample data for cards that would otherwise render empty
283 const sampleData: Record<string, Record<string, unknown>> = {
284 text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' },
285 link: {
286 href: 'https://bsky.app',
287 title: 'Bluesky',
288 domain: 'bsky.app',
289 description: 'Social networking that gives you choice',
290 hasFetched: true
291 },
292 image: {
293 image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600',
294 alt: 'Mountain landscape'
295 },
296 button: { text: 'Visit Bluesky', href: 'https://bsky.app' },
297 bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' },
298 blueskyPost: {
299 uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y',
300 href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y'
301 },
302 blueskyProfile: {
303 handle: 'bsky.app',
304 displayName: 'Bluesky',
305 avatar:
306 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg'
307 },
308 blueskyMedia: {},
309 latestPost: {},
310 youtubeVideo: {
311 youtubeId: 'dQw4w9WgXcQ',
312 poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
313 href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
314 showInline: true
315 },
316 'spotify-list-embed': {
317 spotifyType: 'album',
318 spotifyId: '4aawyAB9vmqN3uQ7FjRGTy',
319 href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy'
320 },
321 latestLivestream: {},
322 livestreamEmbed: {
323 href: 'https://stream.place/',
324 embed: 'https://stream.place/embed/'
325 },
326 mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' },
327 gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' },
328 event: {
329 uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q'
330 },
331 guestbook: { label: 'Guestbook' },
332 githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' },
333 photoGallery: {
334 galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w'
335 },
336 atprotocollections: {},
337 publicationList: {},
338 recentPopfeedReviews: {},
339 recentTealFMPlays: {},
340 statusphere: { emoji: '✨' },
341 vcard: {},
342 'fluid-text': { text: 'Hello World' },
343 draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true },
344 clock: {},
345 countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() },
346 timer: {},
347 'dino-game': {},
348 tetris: {},
349 updatedBlentos: {}
350 };
351
352 // Labels for cards that support canHaveLabel
353 const sampleLabels: Record<string, string> = {
354 image: 'Mountain Landscape',
355 mapLocation: 'Eiffel Tower',
356 gif: 'Cat Typing',
357 bigsocial: 'Bluesky',
358 guestbook: 'Guestbook',
359 statusphere: 'My Status',
360 recentPopfeedReviews: 'My Reviews',
361 recentTealFMPlays: 'Recently Played',
362 clock: 'Local Time',
363 countdown: 'Launch Day',
364 timer: 'Timer',
365 'dino-game': 'Dino Game',
366 tetris: 'Tetris',
367 blueskyMedia: 'Bluesky Media'
368 };
369
370 const newItems: Item[] = [];
371 let cursorY = 0;
372 let mobileCursorY = 0;
373
374 for (const group of sortedGroups) {
375 const defs = grouped.get(group)!;
376
377 // Add a section heading for the group
378 const heading = createEmptyCard(data.page);
379 heading.cardType = 'section';
380 heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 };
381 heading.w = COLUMNS;
382 heading.h = 1;
383 heading.x = 0;
384 heading.y = cursorY;
385 heading.mobileW = COLUMNS;
386 heading.mobileH = 2;
387 heading.mobileX = 0;
388 heading.mobileY = mobileCursorY;
389 newItems.push(heading);
390 cursorY += 1;
391 mobileCursorY += 2;
392
393 // Place cards in rows
394 let rowX = 0;
395 let rowMaxH = 0;
396 let mobileRowX = 0;
397 let mobileRowMaxH = 0;
398
399 for (const def of defs) {
400 if (def.type === 'section' || def.type === 'embed') continue;
401
402 const item = createEmptyCard(data.page);
403 item.cardType = def.type;
404 item.cardData = {};
405 def.createNew?.(item);
406
407 // Merge in sample data (without overwriting createNew defaults)
408 const extra = sampleData[def.type];
409 if (extra) {
410 item.cardData = { ...item.cardData, ...extra };
411 }
412
413 // Set item-level color for cards that need it
414 if (def.type === 'button') {
415 item.color = 'transparent';
416 }
417
418 // Add label if card supports it
419 const label = sampleLabels[def.type];
420 if (label && def.canHaveLabel) {
421 item.cardData.label = label;
422 }
423
424 // Desktop layout
425 if (rowX + item.w > COLUMNS) {
426 cursorY += rowMaxH;
427 rowX = 0;
428 rowMaxH = 0;
429 }
430 item.x = rowX;
431 item.y = cursorY;
432 rowX += item.w;
433 rowMaxH = Math.max(rowMaxH, item.h);
434
435 // Mobile layout
436 if (mobileRowX + item.mobileW > COLUMNS) {
437 mobileCursorY += mobileRowMaxH;
438 mobileRowX = 0;
439 mobileRowMaxH = 0;
440 }
441 item.mobileX = mobileRowX;
442 item.mobileY = mobileCursorY;
443 mobileRowX += item.mobileW;
444 mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH);
445
446 newItems.push(item);
447 }
448
449 // Move cursor past last row
450 cursorY += rowMaxH;
451 mobileCursorY += mobileRowMaxH;
452 }
453
454 items = newItems;
455 onLayoutChanged();
456 }
457
458 let copyInput = $state('');
459 let isCopying = $state(false);
460
461 async function copyPageFrom() {
462 const input = copyInput.trim();
463 if (!input) return;
464
465 isCopying = true;
466 try {
467 // Parse "handle" or "handle/page"
468 const parts = input.split('/');
469 const handle = parts[0];
470 const pageName = parts[1] || 'self';
471
472 const did = await resolveHandle({ handle: handle as `${string}.${string}` });
473 if (!did) throw new Error('Could not resolve handle');
474
475 const records = await listRecords({ did, collection: 'app.blento.card' });
476 const targetPage = 'blento.' + pageName;
477
478 const copiedCards: Item[] = records
479 .map((r) => ({ ...r.value }) as Item)
480 .filter((card) => {
481 // v0/v1 cards without page field belong to blento.self
482 if (!card.page) return targetPage === 'blento.self';
483 return card.page === targetPage;
484 })
485 .map((card) => {
486 // Apply v0→v1 migration (coords were halved in old format)
487 if (!card.version) {
488 card.x *= 2;
489 card.y *= 2;
490 card.h *= 2;
491 card.w *= 2;
492 card.mobileX *= 2;
493 card.mobileY *= 2;
494 card.mobileH *= 2;
495 card.mobileW *= 2;
496 card.version = 1;
497 }
498
499 // Convert blob refs to CDN URLs using source DID
500 if (card.cardData) {
501 for (const key of Object.keys(card.cardData)) {
502 const val = card.cardData[key];
503 if (val && typeof val === 'object' && val.$type === 'blob') {
504 const url = getCDNImageBlobUrl({ did, blob: val });
505 if (url) card.cardData[key] = url;
506 }
507 }
508 }
509
510 // Regenerate ID and assign to current page
511 card.id = TID.now();
512 card.page = data.page;
513 return card;
514 });
515
516 if (copiedCards.length === 0) {
517 toast.error('No cards found on that page');
518 return;
519 }
520
521 fixAllCollisions(copiedCards);
522 fixAllCollisions(copiedCards, true);
523 compactItems(copiedCards);
524 compactItems(copiedCards, true);
525
526 items = copiedCards;
527 onLayoutChanged();
528 toast.success(`Copied ${copiedCards.length} cards from ${handle}`);
529 } catch (e) {
530 console.error('Failed to copy page:', e);
531 toast.error('Failed to copy page');
532 } finally {
533 isCopying = false;
534 }
535 }
536
537 let debugPoint = $state({ x: 0, y: 0 });
538
539 function getGridPosition(
540 clientX: number,
541 clientY: number
542 ):
543 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null }
544 | undefined {
545 if (!container || !activeDragElement.item) return;
546
547 // x, y represent the top-left corner of the dragged card
548 const x = clientX + activeDragElement.mouseDeltaX;
549 const y = clientY + activeDragElement.mouseDeltaY;
550
551 const rect = container.getBoundingClientRect();
552 const currentMargin = isMobile ? mobileMargin : margin;
553 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
554
555 // Get card dimensions based on current view mode
556 const cardW = isMobile
557 ? (activeDragElement.item?.mobileW ?? activeDragElement.w)
558 : activeDragElement.w;
559 const cardH = isMobile
560 ? (activeDragElement.item?.mobileH ?? activeDragElement.h)
561 : activeDragElement.h;
562
563 // Get dragged card's original position
564 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
565
566 const draggedOrigY = draggedOrigPos
567 ? isMobile
568 ? draggedOrigPos.mobileY
569 : draggedOrigPos.y
570 : 0;
571
572 // Calculate raw grid position based on top-left of dragged card
573 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
574 gridX = Math.floor(gridX / 2) * 2;
575
576 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0);
577
578 if (isMobile) {
579 gridX = Math.floor(gridX / 2) * 2;
580 gridY = Math.floor(gridY / 2) * 2;
581 }
582
583 // Find if we're hovering over another card (using ORIGINAL positions)
584 const centerGridY = gridY + cardH / 2;
585 const centerGridX = gridX + cardW / 2;
586
587 let swapWithId: string | null = null;
588 let placement: 'above' | 'below' | null = null;
589
590 for (const other of items) {
591 if (other === activeDragElement.item) continue;
592
593 // Use original positions for hit testing
594 const origPos = activeDragElement.originalPositions.get(other.id);
595 if (!origPos) continue;
596
597 const otherX = isMobile ? origPos.mobileX : origPos.x;
598 const otherY = isMobile ? origPos.mobileY : origPos.y;
599 const otherW = isMobile ? other.mobileW : other.w;
600 const otherH = isMobile ? other.mobileH : other.h;
601
602 // Check if dragged card's center point is within this card's original bounds
603 if (
604 centerGridX >= otherX &&
605 centerGridX < otherX + otherW &&
606 centerGridY >= otherY &&
607 centerGridY < otherY + otherH
608 ) {
609 // Check if this is a swap situation:
610 // Cards have the same dimensions and are on the same row
611 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY;
612
613 if (canSwap) {
614 // Swap positions
615 swapWithId = other.id;
616 gridX = otherX;
617 gridY = otherY;
618 placement = null;
619
620 activeDragElement.lastTargetId = other.id;
621 activeDragElement.lastPlacement = null;
622 } else {
623 // Vertical placement (above/below)
624 // Detect drag direction: if dragging up, always place above
625 const isDraggingUp = gridY < draggedOrigY;
626
627 if (isDraggingUp) {
628 // When dragging up, always place above
629 placement = 'above';
630 } else {
631 // When dragging down, use top/bottom half logic
632 const midpointY = otherY + otherH / 2;
633 const hysteresis = 0.3;
634
635 if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) {
636 if (activeDragElement.lastPlacement === 'above') {
637 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above';
638 } else {
639 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below';
640 }
641 } else {
642 placement = centerGridY < midpointY ? 'above' : 'below';
643 }
644 }
645
646 activeDragElement.lastTargetId = other.id;
647 activeDragElement.lastPlacement = placement;
648
649 if (placement === 'above') {
650 gridY = otherY;
651 } else {
652 gridY = otherY + otherH;
653 }
654 }
655 break;
656 }
657 }
658
659 // If we're not over any card, clear the tracking
660 if (!swapWithId && !placement) {
661 activeDragElement.lastTargetId = null;
662 activeDragElement.lastPlacement = null;
663 }
664
665 debugPoint.x = x - rect.left;
666 debugPoint.y = y - rect.top + currentMargin;
667
668 return { x: gridX, y: gridY, swapWithId, placement };
669 }
670
671 function getDragXY(
672 e: DragEvent & {
673 currentTarget: EventTarget & HTMLDivElement;
674 }
675 ) {
676 return getGridPosition(e.clientX, e.clientY);
677 }
678
679 // Touch drag system (instant drag on selected card)
680 let touchDragActive = $state(false);
681
682 function touchStart(e: TouchEvent) {
683 if (!selectedCardId || !container) return;
684 const touch = e.touches[0];
685 if (!touch) return;
686
687 // Check if the touch is on the selected card element
688 const target = (e.target as HTMLElement)?.closest?.('.card');
689 if (!target || target.id !== selectedCardId) return;
690
691 const item = items.find((i) => i.id === selectedCardId);
692 if (!item || item.cardData?.locked) return;
693
694 // Start dragging immediately
695 touchDragActive = true;
696
697 const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement;
698 if (!cardEl) return;
699
700 activeDragElement.element = cardEl;
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 = cardEl.getBoundingClientRect();
717 activeDragElement.mouseDeltaX = rect.left - touch.clientX;
718 activeDragElement.mouseDeltaY = rect.top - touch.clientY;
719 }
720
721 function touchMove(e: TouchEvent) {
722 if (!touchDragActive) return;
723
724 const touch = e.touches[0];
725 if (!touch) return;
726
727 e.preventDefault();
728
729 const result = getGridPosition(touch.clientX, touch.clientY);
730 if (!result || !activeDragElement.item) return;
731
732 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
733
734 // Reset all items to original positions first
735 for (const it of items) {
736 const origPos = activeDragElement.originalPositions.get(it.id);
737 if (origPos && it !== activeDragElement.item) {
738 if (isMobile) {
739 it.mobileX = origPos.mobileX;
740 it.mobileY = origPos.mobileY;
741 } else {
742 it.x = origPos.x;
743 it.y = origPos.y;
744 }
745 }
746 }
747
748 // Update dragged item position
749 if (isMobile) {
750 activeDragElement.item.mobileX = result.x;
751 activeDragElement.item.mobileY = result.y;
752 } else {
753 activeDragElement.item.x = result.x;
754 activeDragElement.item.y = result.y;
755 }
756
757 // Handle horizontal swap
758 if (result.swapWithId && draggedOrigPos) {
759 const swapTarget = items.find((it) => it.id === result.swapWithId);
760 if (swapTarget) {
761 if (isMobile) {
762 swapTarget.mobileX = draggedOrigPos.mobileX;
763 swapTarget.mobileY = draggedOrigPos.mobileY;
764 } else {
765 swapTarget.x = draggedOrigPos.x;
766 swapTarget.y = draggedOrigPos.y;
767 }
768 }
769 }
770
771 fixCollisions(items, activeDragElement.item, isMobile);
772
773 // Auto-scroll near edges
774 const scrollZone = 100;
775 const scrollSpeed = 10;
776 const viewportHeight = window.innerHeight;
777
778 if (touch.clientY < scrollZone) {
779 const intensity = 1 - touch.clientY / scrollZone;
780 window.scrollBy(0, -scrollSpeed * intensity);
781 } else if (touch.clientY > viewportHeight - scrollZone) {
782 const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone;
783 window.scrollBy(0, scrollSpeed * intensity);
784 }
785 }
786
787 function touchEnd() {
788 if (touchDragActive && activeDragElement.item) {
789 // Finalize position
790 fixCollisions(items, activeDragElement.item, isMobile);
791 onLayoutChanged();
792
793 activeDragElement.x = -1;
794 activeDragElement.y = -1;
795 activeDragElement.element = null;
796 activeDragElement.item = null;
797 activeDragElement.lastTargetId = null;
798 activeDragElement.lastPlacement = null;
799 }
800
801 touchDragActive = false;
802 }
803
804 // Only register non-passive touchmove when actively dragging
805 $effect(() => {
806 const el = container;
807 if (!touchDragActive || !el) return;
808
809 el.addEventListener('touchmove', touchMove, { passive: false });
810 return () => {
811 el.removeEventListener('touchmove', touchMove);
812 };
813 });
814
815 let linkValue = $state('');
816
817 function addLink(url: string, specificCardDef?: CardDefinition) {
818 let link = validateLink(url);
819 if (!link) {
820 toast.error('invalid link');
821 return;
822 }
823 let item = createEmptyCard(data.page);
824
825 if (specificCardDef?.onUrlHandler?.(link, item)) {
826 item.cardType = specificCardDef.type;
827 newItem.item = item;
828 saveNewItem();
829 toast(specificCardDef.name + ' added!');
830 return;
831 }
832
833 for (const cardDef of AllCardDefinitions.toSorted(
834 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
835 )) {
836 if (cardDef.onUrlHandler?.(link, item)) {
837 item.cardType = cardDef.type;
838
839 newItem.item = item;
840 saveNewItem();
841 toast(cardDef.name + ' added!');
842 break;
843 }
844 }
845 }
846
847 function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
848 return new Promise((resolve) => {
849 const img = new Image();
850 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
851 img.onerror = () => resolve({ width: 1, height: 1 });
852 img.src = src;
853 });
854 }
855
856 function getBestGridSize(
857 imageWidth: number,
858 imageHeight: number,
859 candidates: [number, number][]
860 ): [number, number] {
861 const imageRatio = imageWidth / imageHeight;
862 let best: [number, number] = candidates[0];
863 let bestDiff = Infinity;
864
865 for (const candidate of candidates) {
866 const gridRatio = candidate[0] / candidate[1];
867 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio));
868 if (diff < bestDiff) {
869 bestDiff = diff;
870 best = candidate;
871 }
872 }
873
874 return best;
875 }
876
877 const desktopSizeCandidates: [number, number][] = [
878 [2, 2],
879 [2, 4],
880 [4, 2],
881 [4, 4],
882 [4, 6],
883 [6, 4]
884 ];
885 const mobileSizeCandidates: [number, number][] = [
886 [4, 4],
887 [4, 6],
888 [4, 8],
889 [6, 4],
890 [8, 4],
891 [8, 6]
892 ];
893
894 async function processImageFile(file: File, gridX?: number, gridY?: number) {
895 const isGif = file.type === 'image/gif';
896
897 // Don't compress GIFs to preserve animation
898 const objectUrl = URL.createObjectURL(file);
899
900 let item = createEmptyCard(data.page);
901
902 item.cardType = isGif ? 'gif' : 'image';
903 item.cardData = {
904 image: { blob: file, objectUrl }
905 };
906
907 // Size card based on image aspect ratio
908 const { width, height } = await getImageDimensions(objectUrl);
909 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates);
910 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates);
911 item.w = dw;
912 item.h = dh;
913 item.mobileW = mw;
914 item.mobileH = mh;
915
916 // If grid position is provided (image dropped on grid)
917 if (gridX !== undefined && gridY !== undefined) {
918 if (isMobile) {
919 item.mobileX = gridX;
920 item.mobileY = gridY;
921 // Derive desktop Y from mobile
922 item.x = Math.floor((COLUMNS - item.w) / 2);
923 item.x = Math.floor(item.x / 2) * 2;
924 item.y = Math.max(0, Math.round(gridY / 2));
925 } else {
926 item.x = gridX;
927 item.y = gridY;
928 // Derive mobile Y from desktop
929 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2);
930 item.mobileX = Math.floor(item.mobileX / 2) * 2;
931 item.mobileY = Math.max(0, Math.round(gridY * 2));
932 }
933
934 items = [...items, item];
935 fixCollisions(items, item, isMobile);
936 fixCollisions(items, item, !isMobile);
937 } else {
938 const viewportCenter = getViewportCenterGridY();
939 setPositionOfNewItem(item, items, viewportCenter);
940 items = [...items, item];
941 fixCollisions(items, item, false, true);
942 fixCollisions(items, item, true, true);
943 compactItems(items, false);
944 compactItems(items, true);
945 }
946
947 onLayoutChanged();
948
949 await tick();
950
951 scrollToItem(item, isMobile, container);
952 }
953
954 function handleImageDragOver(event: DragEvent) {
955 const dt = event.dataTransfer;
956 if (!dt) return;
957
958 let hasImage = false;
959 if (dt.items) {
960 for (let i = 0; i < dt.items.length; i++) {
961 const item = dt.items[i];
962 if (item && item.kind === 'file' && item.type.startsWith('image/')) {
963 hasImage = true;
964 break;
965 }
966 }
967 } else if (dt.files) {
968 for (let i = 0; i < dt.files.length; i++) {
969 const file = dt.files[i];
970 if (file?.type.startsWith('image/')) {
971 hasImage = true;
972 break;
973 }
974 }
975 }
976
977 if (hasImage) {
978 event.preventDefault();
979 event.stopPropagation();
980
981 imageDragOver = true;
982 }
983 }
984
985 function handleImageDragLeave(event: DragEvent) {
986 event.preventDefault();
987 event.stopPropagation();
988 imageDragOver = false;
989 }
990
991 async function handleImageDrop(event: DragEvent) {
992 event.preventDefault();
993 event.stopPropagation();
994 const dropX = event.clientX;
995 const dropY = event.clientY;
996 imageDragOver = false;
997
998 if (!event.dataTransfer?.files?.length) return;
999
1000 const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
1001 f?.type.startsWith('image/')
1002 );
1003 if (imageFiles.length === 0) return;
1004
1005 // Calculate starting grid position from drop coordinates
1006 let gridX = 0;
1007 let gridY = 0;
1008 if (container) {
1009 const rect = container.getBoundingClientRect();
1010 const currentMargin = isMobile ? mobileMargin : margin;
1011 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
1012 const cardW = isMobile ? 4 : 2;
1013
1014 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
1015 gridX = Math.floor(gridX / 2) * 2;
1016
1017 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0);
1018 if (isMobile) {
1019 gridY = Math.floor(gridY / 2) * 2;
1020 }
1021 }
1022
1023 for (let i = 0; i < imageFiles.length; i++) {
1024 // First image gets the drop position, rest use normal placement
1025 if (i === 0) {
1026 await processImageFile(imageFiles[i], gridX, gridY);
1027 } else {
1028 await processImageFile(imageFiles[i]);
1029 }
1030 }
1031 }
1032
1033 async function handleImageInputChange(event: Event) {
1034 const target = event.target as HTMLInputElement;
1035 if (!target.files || target.files.length < 1) return;
1036
1037 const files = Array.from(target.files);
1038
1039 if (files.length === 1) {
1040 // Single file: use default positioning
1041 await processImageFile(files[0]);
1042 } else {
1043 // Multiple files: place in grid pattern starting from first available position
1044 let gridX = 0;
1045 let gridY = maxHeight;
1046 const cardW = isMobile ? 4 : 2;
1047 const cardH = isMobile ? 4 : 2;
1048
1049 for (const file of files) {
1050 await processImageFile(file, gridX, gridY);
1051
1052 // Move to next cell position
1053 gridX += cardW;
1054 if (gridX + cardW > COLUMNS) {
1055 gridX = 0;
1056 gridY += cardH;
1057 }
1058 }
1059 }
1060
1061 // Reset the input so the same file can be selected again
1062 target.value = '';
1063 }
1064
1065 async function processVideoFile(file: File) {
1066 const objectUrl = URL.createObjectURL(file);
1067
1068 let item = createEmptyCard(data.page);
1069
1070 item.cardType = 'video';
1071 item.cardData = {
1072 blob: file,
1073 objectUrl
1074 };
1075
1076 const viewportCenter = getViewportCenterGridY();
1077 setPositionOfNewItem(item, items, viewportCenter);
1078 items = [...items, item];
1079 fixCollisions(items, item, false, true);
1080 fixCollisions(items, item, true, true);
1081 compactItems(items, false);
1082 compactItems(items, true);
1083
1084 onLayoutChanged();
1085
1086 await tick();
1087
1088 scrollToItem(item, isMobile, container);
1089 }
1090
1091 async function handleVideoInputChange(event: Event) {
1092 const target = event.target as HTMLInputElement;
1093 if (!target.files || target.files.length < 1) return;
1094
1095 const files = Array.from(target.files);
1096
1097 for (const file of files) {
1098 await processVideoFile(file);
1099 }
1100
1101 // Reset the input so the same file can be selected again
1102 target.value = '';
1103 }
1104
1105 let showCardCommand = $state(false);
1106</script>
1107
1108<svelte:body
1109 onpaste={(event) => {
1110 if (isTyping()) return;
1111
1112 const text = event.clipboardData?.getData('text/plain');
1113 const link = validateLink(text, false);
1114 if (!link) return;
1115
1116 addLink(link);
1117 }}
1118/>
1119
1120<svelte:window
1121 ondragover={handleImageDragOver}
1122 ondragleave={handleImageDragLeave}
1123 ondrop={handleImageDrop}
1124/>
1125
1126<Head
1127 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar}
1128 title={getName(data)}
1129 image={'/' + data.handle + '/og.png'}
1130 accentColor={data.publication?.preferences?.accentColor}
1131 baseColor={data.publication?.preferences?.baseColor}
1132/>
1133
1134<Account {data} />
1135
1136<Context {data}>
1137 <CardCommand
1138 bind:open={showCardCommand}
1139 onselect={(cardDef: CardDefinition) => {
1140 if (cardDef.type === 'image') {
1141 const input = document.getElementById('image-input') as HTMLInputElement;
1142 if (input) {
1143 input.click();
1144 return;
1145 }
1146 } else if (cardDef.type === 'video') {
1147 const input = document.getElementById('video-input') as HTMLInputElement;
1148 if (input) {
1149 input.click();
1150 return;
1151 }
1152 } else {
1153 newCard(cardDef.type);
1154 }
1155 }}
1156 onlink={(url, cardDef) => {
1157 addLink(url, cardDef);
1158 }}
1159 />
1160
1161 <Controls bind:data />
1162
1163 {#if showingMobileView}
1164 <div
1165 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full"
1166 ></div>
1167 {/if}
1168
1169 {#if newItem.modal && newItem.item}
1170 <newItem.modal
1171 oncreate={() => {
1172 saveNewItem();
1173 }}
1174 bind:item={newItem.item}
1175 oncancel={() => {
1176 newItem = {};
1177 }}
1178 />
1179 {/if}
1180
1181 <SaveModal
1182 bind:open={showSaveModal}
1183 success={saveSuccess}
1184 handle={data.handle}
1185 page={data.page}
1186 />
1187
1188 <div
1189 class={[
1190 '@container/wrapper relative w-full',
1191 showingMobileView
1192 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90'
1193 : ''
1194 ]}
1195 >
1196 {#if !getHideProfileSection(data)}
1197 <EditableProfile bind:data hideBlento={showLoginOnEditPage} />
1198 {/if}
1199
1200 <div
1201 class={[
1202 'pointer-events-none relative mx-auto max-w-lg',
1203 !getHideProfileSection(data) && getProfilePosition(data) === 'side'
1204 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4'
1205 : '@5xl/wrapper:max-w-4xl'
1206 ]}
1207 >
1208 <div class="pointer-events-none"></div>
1209 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
1210 <div
1211 bind:this={container}
1212 onclick={(e) => {
1213 // Deselect when tapping empty grid space
1214 if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) {
1215 selectedCardId = null;
1216 }
1217 }}
1218 ontouchstart={touchStart}
1219 ontouchend={touchEnd}
1220 ondragover={(e) => {
1221 e.preventDefault();
1222
1223 const result = getDragXY(e);
1224 if (!result) return;
1225
1226 activeDragElement.x = result.x;
1227 activeDragElement.y = result.y;
1228
1229 if (activeDragElement.item) {
1230 // Get dragged card's original position for swapping
1231 const draggedOrigPos = activeDragElement.originalPositions.get(
1232 activeDragElement.item.id
1233 );
1234
1235 // Reset all items to original positions first
1236 for (const it of items) {
1237 const origPos = activeDragElement.originalPositions.get(it.id);
1238 if (origPos && it !== activeDragElement.item) {
1239 if (isMobile) {
1240 it.mobileX = origPos.mobileX;
1241 it.mobileY = origPos.mobileY;
1242 } else {
1243 it.x = origPos.x;
1244 it.y = origPos.y;
1245 }
1246 }
1247 }
1248
1249 // Update dragged item position
1250 if (isMobile) {
1251 activeDragElement.item.mobileX = result.x;
1252 activeDragElement.item.mobileY = result.y;
1253 } else {
1254 activeDragElement.item.x = result.x;
1255 activeDragElement.item.y = result.y;
1256 }
1257
1258 // Handle horizontal swap
1259 if (result.swapWithId && draggedOrigPos) {
1260 const swapTarget = items.find((it) => it.id === result.swapWithId);
1261 if (swapTarget) {
1262 // Move swap target to dragged card's original position
1263 if (isMobile) {
1264 swapTarget.mobileX = draggedOrigPos.mobileX;
1265 swapTarget.mobileY = draggedOrigPos.mobileY;
1266 } else {
1267 swapTarget.x = draggedOrigPos.x;
1268 swapTarget.y = draggedOrigPos.y;
1269 }
1270 }
1271 }
1272
1273 // Now fix collisions (with compacting)
1274 fixCollisions(items, activeDragElement.item, isMobile);
1275 }
1276
1277 // Auto-scroll when dragging near top or bottom of viewport
1278 const scrollZone = 100;
1279 const scrollSpeed = 10;
1280 const viewportHeight = window.innerHeight;
1281
1282 if (e.clientY < scrollZone) {
1283 // Near top - scroll up
1284 const intensity = 1 - e.clientY / scrollZone;
1285 window.scrollBy(0, -scrollSpeed * intensity);
1286 } else if (e.clientY > viewportHeight - scrollZone) {
1287 // Near bottom - scroll down
1288 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
1289 window.scrollBy(0, scrollSpeed * intensity);
1290 }
1291 }}
1292 ondragend={async (e) => {
1293 e.preventDefault();
1294 // safari fix
1295 activeDragElement.x = -1;
1296 activeDragElement.y = -1;
1297 activeDragElement.element = null;
1298 activeDragElement.item = null;
1299 activeDragElement.lastTargetId = null;
1300 activeDragElement.lastPlacement = null;
1301 return true;
1302 }}
1303 class={[
1304 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8',
1305 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed'
1306 ]}
1307 >
1308 {#each items as item, i (item.id)}
1309 <!-- {#if item !== activeDragElement.item} -->
1310 <BaseEditingCard
1311 bind:item={items[i]}
1312 ondelete={() => {
1313 items = items.filter((it) => it !== item);
1314 compactItems(items, false);
1315 compactItems(items, true);
1316 onLayoutChanged();
1317 }}
1318 onsetsize={(newW: number, newH: number) => {
1319 if (isMobile) {
1320 item.mobileW = newW;
1321 item.mobileH = newH;
1322 } else {
1323 item.w = newW;
1324 item.h = newH;
1325 }
1326
1327 fixCollisions(items, item, isMobile);
1328 onLayoutChanged();
1329 }}
1330 ondragstart={(e: DragEvent) => {
1331 const target = e.currentTarget as HTMLDivElement;
1332 activeDragElement.element = target;
1333 activeDragElement.w = item.w;
1334 activeDragElement.h = item.h;
1335 activeDragElement.item = item;
1336 // fix for div shadow during drag and drop
1337 const transparent = document.createElement('div');
1338 transparent.style.position = 'fixed';
1339 transparent.style.top = '-1000px';
1340 transparent.style.width = '1px';
1341 transparent.style.height = '1px';
1342 document.body.appendChild(transparent);
1343 e.dataTransfer?.setDragImage(transparent, 0, 0);
1344 requestAnimationFrame(() => transparent.remove());
1345
1346 // Store original positions of all items
1347 activeDragElement.originalPositions = new Map();
1348 for (const it of items) {
1349 activeDragElement.originalPositions.set(it.id, {
1350 x: it.x,
1351 y: it.y,
1352 mobileX: it.mobileX,
1353 mobileY: it.mobileY
1354 });
1355 }
1356
1357 const rect = target.getBoundingClientRect();
1358 activeDragElement.mouseDeltaX = rect.left - e.clientX;
1359 activeDragElement.mouseDeltaY = rect.top - e.clientY;
1360 }}
1361 >
1362 <EditingCard bind:item={items[i]} />
1363 </BaseEditingCard>
1364 <!-- {/if} -->
1365 {/each}
1366
1367 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div>
1368 </div>
1369 </div>
1370 </div>
1371
1372 <EditBar
1373 {data}
1374 bind:linkValue
1375 bind:isSaving
1376 bind:showingMobileView
1377 {hasUnsavedChanges}
1378 {newCard}
1379 {addLink}
1380 {save}
1381 {handleImageInputChange}
1382 {handleVideoInputChange}
1383 showCardCommand={() => {
1384 showCardCommand = true;
1385 }}
1386 {selectedCard}
1387 {isMobile}
1388 {isCoarse}
1389 ondeselect={() => {
1390 selectedCardId = null;
1391 }}
1392 ondelete={() => {
1393 if (selectedCard) {
1394 items = items.filter((it) => it.id !== selectedCardId);
1395 compactItems(items, false);
1396 compactItems(items, true);
1397 onLayoutChanged();
1398 selectedCardId = null;
1399 }
1400 }}
1401 onsetsize={(w: number, h: number) => {
1402 if (selectedCard) {
1403 if (isMobile) {
1404 selectedCard.mobileW = w;
1405 selectedCard.mobileH = h;
1406 } else {
1407 selectedCard.w = w;
1408 selectedCard.h = h;
1409 }
1410 fixCollisions(items, selectedCard, isMobile);
1411 onLayoutChanged();
1412 }
1413 }}
1414 />
1415
1416 <Toaster />
1417
1418 <FloatingEditButton {data} />
1419
1420 {#if dev}
1421 <div
1422 class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs"
1423 >
1424 <span>editedOn: {editedOn}</span>
1425 <button class="underline" onclick={addAllCardTypes}>+ all cards</button>
1426 <input
1427 bind:value={copyInput}
1428 placeholder="handle/page"
1429 class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5"
1430 onkeydown={(e) => {
1431 if (e.key === 'Enter') copyPageFrom();
1432 }}
1433 />
1434 <button class="underline" onclick={copyPageFrom} disabled={isCopying}>
1435 {isCopying ? 'copying...' : 'copy'}
1436 </button>
1437 </div>
1438 {/if}
1439</Context>