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