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