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