your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { Button, Modal, toast, Toaster } from '@foxui/core';
3 import { COLUMNS } from '$lib';
4 import {
5 checkAndUploadImage,
6 createEmptyCard,
7 getHideProfileSection,
8 getProfilePosition,
9 getName,
10 isTyping,
11 savePage,
12 scrollToItem,
13 validateLink,
14 getImage
15 } from '../helper';
16 import EditableProfile from './EditableProfile.svelte';
17 import type { Item, WebsiteData } from '../types';
18 import { innerWidth } from 'svelte/reactivity/window';
19 import EditingCard from '../cards/_base/Card/EditingCard.svelte';
20 import { AllCardDefinitions, CardDefinitionsByType } from '../cards';
21 import { tick, type Component } from 'svelte';
22 import type { CardDefinition, CreationModalComponentProps } from '../cards/types';
23 import { dev } from '$app/environment';
24 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context';
25 import BaseEditingCard from '../cards/_base/BaseCard/BaseEditingCard.svelte';
26 import Context from './Context.svelte';
27 import Head from './Head.svelte';
28 import Account from './Account.svelte';
29 import EditBar from './EditBar.svelte';
30 import SaveModal from './SaveModal.svelte';
31 import FloatingEditButton from './FloatingEditButton.svelte';
32 import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto';
33 import * as TID from '@atcute/tid';
34 import { launchConfetti } from '@foxui/visual';
35 import Controls from './Controls.svelte';
36 import CardCommand from '$lib/components/card-command/CardCommand.svelte';
37 import { SvelteMap } from 'svelte/reactivity';
38 import {
39 fixCollisions,
40 compactItems,
41 fixAllCollisions,
42 setPositionOfNewItem,
43 shouldMirror,
44 mirrorLayout,
45 getViewportCenterGridY,
46 EditableGrid
47 } from '$lib/layout';
48
49 let {
50 data
51 }: {
52 data: WebsiteData;
53 } = $props();
54
55 // Check if floating login button will be visible (to hide MadeWithBlento)
56 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn);
57
58 // svelte-ignore state_referenced_locally
59 let items: Item[] = $state(data.cards);
60
61 // svelte-ignore state_referenced_locally
62 let publication = $state(JSON.stringify(data.publication));
63
64 // svelte-ignore state_referenced_locally
65 let savedItemsSnapshot = JSON.stringify(data.cards);
66
67 let hasUnsavedChanges = $state(false);
68
69 // Detect card content and publication changes (e.g. sidebar edits)
70 // The guard ensures JSON.stringify only runs while no changes are detected yet.
71 // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations
72 // but the early return makes it effectively free.
73 $effect(() => {
74 if (hasUnsavedChanges) return;
75 if (
76 JSON.stringify(items) !== savedItemsSnapshot ||
77 JSON.stringify(data.publication) !== publication
78 ) {
79 hasUnsavedChanges = true;
80 }
81 });
82
83 // Warn user before closing tab if there are unsaved changes
84 $effect(() => {
85 function handleBeforeUnload(e: BeforeUnloadEvent) {
86 if (hasUnsavedChanges) {
87 e.preventDefault();
88 return '';
89 }
90 }
91
92 window.addEventListener('beforeunload', handleBeforeUnload);
93 return () => window.removeEventListener('beforeunload', handleBeforeUnload);
94 });
95
96 let gridContainer: HTMLDivElement | undefined = $state();
97
98 let showingMobileView = $state(false);
99 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024);
100 let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024);
101
102 setIsMobile(() => isMobile);
103
104 // svelte-ignore state_referenced_locally
105 let editedOn = $state(data.publication.preferences?.editedOn ?? 0);
106
107 function onLayoutChanged() {
108 hasUnsavedChanges = true;
109 // Set the bit for the current layout: desktop=1, mobile=2
110 editedOn = editedOn | (isMobile ? 2 : 1);
111 if (shouldMirror(editedOn)) {
112 mirrorLayout(items, isMobile);
113 }
114 }
115
116 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
117 setIsCoarse(() => isCoarse);
118
119 let selectedCardId: string | null = $state(null);
120 let selectedCard = $derived(
121 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null
122 );
123
124 setSelectedCardId(() => selectedCardId);
125 setSelectCard((id: string | null) => {
126 selectedCardId = id;
127 });
128
129 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y);
130 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
131 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
132
133 function newCard(type: string = 'link', cardData?: any) {
134 selectedCardId = null;
135
136 // close sidebar if open
137 const popover = document.getElementById('mobile-menu');
138 if (popover) {
139 popover.hidePopover();
140 }
141
142 let item = createEmptyCard(data.page);
143 item.cardType = type;
144
145 item.cardData = cardData ?? {};
146
147 const cardDef = CardDefinitionsByType[type];
148 cardDef?.createNew?.(item);
149
150 newItem.item = item;
151
152 if (cardDef?.creationModalComponent) {
153 newItem.modal = cardDef.creationModalComponent;
154 } else {
155 saveNewItem();
156 }
157 }
158
159 function cleanupDialogArtifacts() {
160 // bits-ui's body scroll lock and portal may not clean up fully when the
161 // modal is unmounted instead of closed via the open prop.
162 const restore = () => {
163 document.body.style.removeProperty('overflow');
164 document.body.style.removeProperty('pointer-events');
165 document.body.style.removeProperty('padding-right');
166 document.body.style.removeProperty('margin-right');
167 // Remove any orphaned dialog overlay/content elements left by the portal
168 for (const el of document.querySelectorAll('[data-dialog-overlay], [data-dialog-content]')) {
169 el.remove();
170 }
171 };
172 // Run immediately and again after bits-ui's 24ms scheduled cleanup
173 restore();
174 setTimeout(restore, 50);
175 }
176
177 async function saveNewItem() {
178 if (!newItem.item) return;
179 const item = newItem.item;
180
181 const viewportCenter = gridContainer
182 ? getViewportCenterGridY(gridContainer, isMobile)
183 : undefined;
184 setPositionOfNewItem(item, items, viewportCenter);
185
186 items = [...items, item];
187
188 // Push overlapping items down, then compact to fill gaps
189 fixCollisions(items, item, false, true);
190 fixCollisions(items, item, true, true);
191 compactItems(items, false);
192 compactItems(items, true);
193
194 onLayoutChanged();
195
196 newItem = {};
197
198 await tick();
199 cleanupDialogArtifacts();
200
201 scrollToItem(item, isMobile, gridContainer);
202 }
203
204 let isSaving = $state(false);
205 let showSaveModal = $state(false);
206 let saveSuccess = $state(false);
207
208 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({});
209
210 async function save() {
211 isSaving = true;
212 saveSuccess = false;
213 showSaveModal = true;
214
215 try {
216 // Upload profile icon if changed
217 if (data.publication?.icon) {
218 await checkAndUploadImage(data.publication, 'icon');
219 }
220
221 // Persist layout editing state
222 data.publication.preferences ??= {};
223 data.publication.preferences.editedOn = editedOn;
224
225 await savePage(data, items, publication);
226
227 publication = JSON.stringify(data.publication);
228
229 savedItemsSnapshot = JSON.stringify(items);
230 hasUnsavedChanges = false;
231
232 saveSuccess = true;
233
234 launchConfetti();
235
236 // Refresh cached data
237 await fetch('/' + data.handle + '/api/refresh');
238 } catch (error) {
239 console.error(error);
240 showSaveModal = false;
241 toast.error('Error saving page!');
242 } finally {
243 isSaving = false;
244 }
245 }
246
247 function addAllCardTypes() {
248 const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games'];
249 const grouped = new SvelteMap<string, CardDefinition[]>();
250
251 for (const def of AllCardDefinitions) {
252 if (!def.name) continue;
253 const group = def.groups?.[0] ?? 'Other';
254 if (!grouped.has(group)) grouped.set(group, []);
255 grouped.get(group)!.push(def);
256 }
257
258 // Sort groups by predefined order, unknowns at end
259 const sortedGroups = [...grouped.keys()].sort((a, b) => {
260 const ai = groupOrder.indexOf(a);
261 const bi = groupOrder.indexOf(b);
262 return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
263 });
264
265 // Sample data for cards that would otherwise render empty
266 const sampleData: Record<string, Record<string, unknown>> = {
267 text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' },
268 link: {
269 href: 'https://bsky.app',
270 title: 'Bluesky',
271 domain: 'bsky.app',
272 description: 'Social networking that gives you choice',
273 hasFetched: true
274 },
275 image: {
276 image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600',
277 alt: 'Mountain landscape'
278 },
279 button: { text: 'Visit Bluesky', href: 'https://bsky.app' },
280 bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' },
281 blueskyPost: {
282 uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y',
283 href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y'
284 },
285 blueskyProfile: {
286 handle: 'bsky.app',
287 displayName: 'Bluesky',
288 avatar:
289 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg'
290 },
291 blueskyMedia: {},
292 latestPost: {},
293 youtubeVideo: {
294 youtubeId: 'dQw4w9WgXcQ',
295 poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
296 href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
297 showInline: true
298 },
299 'spotify-list-embed': {
300 spotifyType: 'album',
301 spotifyId: '4aawyAB9vmqN3uQ7FjRGTy',
302 href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy'
303 },
304 latestLivestream: {},
305 livestreamEmbed: {
306 href: 'https://stream.place/',
307 embed: 'https://stream.place/embed/'
308 },
309 mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' },
310 gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' },
311 event: {
312 uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q'
313 },
314 guestbook: { label: 'Guestbook' },
315 githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' },
316 photoGallery: {
317 galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w'
318 },
319 atprotocollections: {},
320 publicationList: {},
321 recentPopfeedReviews: {},
322 recentTealFMPlays: {},
323 statusphere: { emoji: '✨' },
324 vcard: {},
325 'fluid-text': { text: 'Hello World' },
326 draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true },
327 clock: {},
328 countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() },
329 timer: {},
330 'dino-game': {},
331 tetris: {},
332 updatedBlentos: {}
333 };
334
335 // Labels for cards that support canHaveLabel
336 const sampleLabels: Record<string, string> = {
337 image: 'Mountain Landscape',
338 mapLocation: 'Eiffel Tower',
339 gif: 'Cat Typing',
340 bigsocial: 'Bluesky',
341 guestbook: 'Guestbook',
342 statusphere: 'My Status',
343 recentPopfeedReviews: 'My Reviews',
344 recentTealFMPlays: 'Recently Played',
345 clock: 'Local Time',
346 countdown: 'Launch Day',
347 timer: 'Timer',
348 'dino-game': 'Dino Game',
349 tetris: 'Tetris',
350 blueskyMedia: 'Bluesky Media'
351 };
352
353 const newItems: Item[] = [];
354 let cursorY = 0;
355 let mobileCursorY = 0;
356
357 for (const group of sortedGroups) {
358 const defs = grouped.get(group)!;
359
360 // Add a section heading for the group
361 const heading = createEmptyCard(data.page);
362 heading.cardType = 'section';
363 heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 };
364 heading.w = COLUMNS;
365 heading.h = 1;
366 heading.x = 0;
367 heading.y = cursorY;
368 heading.mobileW = COLUMNS;
369 heading.mobileH = 2;
370 heading.mobileX = 0;
371 heading.mobileY = mobileCursorY;
372 newItems.push(heading);
373 cursorY += 1;
374 mobileCursorY += 2;
375
376 // Place cards in rows
377 let rowX = 0;
378 let rowMaxH = 0;
379 let mobileRowX = 0;
380 let mobileRowMaxH = 0;
381
382 for (const def of defs) {
383 if (def.type === 'section' || def.type === 'embed') continue;
384
385 const item = createEmptyCard(data.page);
386 item.cardType = def.type;
387 item.cardData = {};
388 def.createNew?.(item);
389
390 // Merge in sample data (without overwriting createNew defaults)
391 const extra = sampleData[def.type];
392 if (extra) {
393 item.cardData = { ...item.cardData, ...extra };
394 }
395
396 // Set item-level color for cards that need it
397 if (def.type === 'button') {
398 item.color = 'transparent';
399 }
400
401 // Add label if card supports it
402 const label = sampleLabels[def.type];
403 if (label && def.canHaveLabel) {
404 item.cardData.label = label;
405 }
406
407 // Desktop layout
408 if (rowX + item.w > COLUMNS) {
409 cursorY += rowMaxH;
410 rowX = 0;
411 rowMaxH = 0;
412 }
413 item.x = rowX;
414 item.y = cursorY;
415 rowX += item.w;
416 rowMaxH = Math.max(rowMaxH, item.h);
417
418 // Mobile layout
419 if (mobileRowX + item.mobileW > COLUMNS) {
420 mobileCursorY += mobileRowMaxH;
421 mobileRowX = 0;
422 mobileRowMaxH = 0;
423 }
424 item.mobileX = mobileRowX;
425 item.mobileY = mobileCursorY;
426 mobileRowX += item.mobileW;
427 mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH);
428
429 newItems.push(item);
430 }
431
432 // Move cursor past last row
433 cursorY += rowMaxH;
434 mobileCursorY += mobileRowMaxH;
435 }
436
437 items = newItems;
438 onLayoutChanged();
439 }
440
441 let copyInput = $state('');
442 let isCopying = $state(false);
443
444 async function copyPageFrom() {
445 const input = copyInput.trim();
446 if (!input) return;
447
448 isCopying = true;
449 try {
450 // Parse "handle" or "handle/page"
451 const parts = input.split('/');
452 const handle = parts[0];
453 const pageName = parts[1] || 'self';
454
455 const did = await resolveHandle({ handle: handle as `${string}.${string}` });
456 if (!did) throw new Error('Could not resolve handle');
457
458 const records = await listRecords({ did, collection: 'app.blento.card' });
459 const targetPage = 'blento.' + pageName;
460
461 const copiedCards: Item[] = records
462 .map((r) => ({ ...r.value }) as Item)
463 .filter((card) => {
464 // v0/v1 cards without page field belong to blento.self
465 if (!card.page) return targetPage === 'blento.self';
466 return card.page === targetPage;
467 })
468 .map((card) => {
469 // Apply v0→v1 migration (coords were halved in old format)
470 if (!card.version) {
471 card.x *= 2;
472 card.y *= 2;
473 card.h *= 2;
474 card.w *= 2;
475 card.mobileX *= 2;
476 card.mobileY *= 2;
477 card.mobileH *= 2;
478 card.mobileW *= 2;
479 card.version = 1;
480 }
481
482 // Convert blob refs to CDN URLs using source DID
483 if (card.cardData) {
484 for (const key of Object.keys(card.cardData)) {
485 const val = card.cardData[key];
486 if (val && typeof val === 'object' && val.$type === 'blob') {
487 const url = getCDNImageBlobUrl({ did, blob: val });
488 if (url) card.cardData[key] = url;
489 }
490 }
491 }
492
493 // Regenerate ID and assign to current page
494 card.id = TID.now();
495 card.page = data.page;
496 return card;
497 });
498
499 if (copiedCards.length === 0) {
500 toast.error('No cards found on that page');
501 return;
502 }
503
504 fixAllCollisions(copiedCards, false);
505 fixAllCollisions(copiedCards, true);
506 compactItems(copiedCards, false);
507 compactItems(copiedCards, true);
508
509 items = copiedCards;
510 onLayoutChanged();
511 toast.success(`Copied ${copiedCards.length} cards from ${handle}`);
512 } catch (e) {
513 console.error('Failed to copy page:', e);
514 toast.error('Failed to copy page');
515 } finally {
516 isCopying = false;
517 }
518 }
519
520 let linkValue = $state('');
521
522 function addLink(url: string, specificCardDef?: CardDefinition) {
523 let link = validateLink(url);
524 if (!link) {
525 toast.error('invalid link');
526 return;
527 }
528 let item = createEmptyCard(data.page);
529
530 if (specificCardDef?.onUrlHandler?.(link, item)) {
531 item.cardType = specificCardDef.type;
532 newItem.item = item;
533 saveNewItem();
534 toast(specificCardDef.name + ' added!');
535 return;
536 }
537
538 for (const cardDef of AllCardDefinitions.toSorted(
539 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
540 )) {
541 if (cardDef.onUrlHandler?.(link, item)) {
542 item.cardType = cardDef.type;
543
544 newItem.item = item;
545 saveNewItem();
546 toast(cardDef.name + ' added!');
547 break;
548 }
549 }
550 }
551
552 function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
553 return new Promise((resolve) => {
554 const img = new Image();
555 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
556 img.onerror = () => resolve({ width: 1, height: 1 });
557 img.src = src;
558 });
559 }
560
561 function getBestGridSize(
562 imageWidth: number,
563 imageHeight: number,
564 candidates: [number, number][]
565 ): [number, number] {
566 const imageRatio = imageWidth / imageHeight;
567 let best: [number, number] = candidates[0];
568 let bestDiff = Infinity;
569
570 for (const candidate of candidates) {
571 const gridRatio = candidate[0] / candidate[1];
572 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio));
573 if (diff < bestDiff) {
574 bestDiff = diff;
575 best = candidate;
576 }
577 }
578
579 return best;
580 }
581
582 const desktopSizeCandidates: [number, number][] = [
583 [2, 2],
584 [2, 4],
585 [4, 2],
586 [4, 4],
587 [4, 6],
588 [6, 4]
589 ];
590 const mobileSizeCandidates: [number, number][] = [
591 [4, 4],
592 [4, 6],
593 [4, 8],
594 [6, 4],
595 [8, 4],
596 [8, 6]
597 ];
598
599 async function processImageFile(file: File, gridX?: number, gridY?: number) {
600 const isGif = file.type === 'image/gif';
601
602 // Don't compress GIFs to preserve animation
603 const objectUrl = URL.createObjectURL(file);
604
605 let item = createEmptyCard(data.page);
606
607 item.cardType = isGif ? 'gif' : 'image';
608 item.cardData = {
609 image: { blob: file, objectUrl }
610 };
611
612 // Size card based on image aspect ratio
613 const { width, height } = await getImageDimensions(objectUrl);
614 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates);
615 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates);
616 item.w = dw;
617 item.h = dh;
618 item.mobileW = mw;
619 item.mobileH = mh;
620
621 // If grid position is provided (image dropped on grid)
622 if (gridX !== undefined && gridY !== undefined) {
623 if (isMobile) {
624 item.mobileX = gridX;
625 item.mobileY = gridY;
626 // Derive desktop Y from mobile
627 item.x = Math.floor((COLUMNS - item.w) / 2);
628 item.x = Math.floor(item.x / 2) * 2;
629 item.y = Math.max(0, Math.round(gridY / 2));
630 } else {
631 item.x = gridX;
632 item.y = gridY;
633 // Derive mobile Y from desktop
634 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2);
635 item.mobileX = Math.floor(item.mobileX / 2) * 2;
636 item.mobileY = Math.max(0, Math.round(gridY * 2));
637 }
638
639 items = [...items, item];
640 fixCollisions(items, item, isMobile);
641 fixCollisions(items, item, !isMobile);
642 } else {
643 const viewportCenter = gridContainer
644 ? getViewportCenterGridY(gridContainer, isMobile)
645 : undefined;
646 setPositionOfNewItem(item, items, viewportCenter);
647 items = [...items, item];
648 fixCollisions(items, item, false, true);
649 fixCollisions(items, item, true, true);
650 compactItems(items, false);
651 compactItems(items, true);
652 }
653
654 onLayoutChanged();
655
656 await tick();
657
658 scrollToItem(item, isMobile, gridContainer);
659 }
660
661 async function handleFileDrop(files: File[], gridX: number, gridY: number) {
662 for (let i = 0; i < files.length; i++) {
663 // First image gets the drop position, rest use normal placement
664 if (i === 0) {
665 await processImageFile(files[i], gridX, gridY);
666 } else {
667 await processImageFile(files[i]);
668 }
669 }
670 }
671
672 async function handleImageInputChange(event: Event) {
673 const target = event.target as HTMLInputElement;
674 if (!target.files || target.files.length < 1) return;
675
676 const files = Array.from(target.files);
677
678 if (files.length === 1) {
679 // Single file: use default positioning
680 await processImageFile(files[0]);
681 } else {
682 // Multiple files: place in grid pattern starting from first available position
683 let gridX = 0;
684 let gridY = maxHeight;
685 const cardW = isMobile ? 4 : 2;
686 const cardH = isMobile ? 4 : 2;
687
688 for (const file of files) {
689 await processImageFile(file, gridX, gridY);
690
691 // Move to next cell position
692 gridX += cardW;
693 if (gridX + cardW > COLUMNS) {
694 gridX = 0;
695 gridY += cardH;
696 }
697 }
698 }
699
700 // Reset the input so the same file can be selected again
701 target.value = '';
702 }
703
704 async function processVideoFile(file: File) {
705 const objectUrl = URL.createObjectURL(file);
706
707 let item = createEmptyCard(data.page);
708
709 item.cardType = 'video';
710 item.cardData = {
711 blob: file,
712 objectUrl
713 };
714
715 const viewportCenter = gridContainer
716 ? getViewportCenterGridY(gridContainer, isMobile)
717 : undefined;
718 setPositionOfNewItem(item, items, viewportCenter);
719 items = [...items, item];
720 fixCollisions(items, item, false, true);
721 fixCollisions(items, item, true, true);
722 compactItems(items, false);
723 compactItems(items, true);
724
725 onLayoutChanged();
726
727 await tick();
728
729 scrollToItem(item, isMobile, gridContainer);
730 }
731
732 async function handleVideoInputChange(event: Event) {
733 const target = event.target as HTMLInputElement;
734 if (!target.files || target.files.length < 1) return;
735
736 const files = Array.from(target.files);
737
738 for (const file of files) {
739 await processVideoFile(file);
740 }
741
742 // Reset the input so the same file can be selected again
743 target.value = '';
744 }
745
746 let showCardCommand = $state(false);
747</script>
748
749<svelte:body
750 onpaste={(event) => {
751 if (isTyping()) return;
752
753 const text = event.clipboardData?.getData('text/plain');
754 const link = validateLink(text, false);
755 if (!link) return;
756
757 addLink(link);
758 }}
759/>
760
761<Head
762 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar}
763 title={getName(data)}
764 image={'/' + data.handle + '/og.png'}
765 accentColor={data.publication?.preferences?.accentColor}
766 baseColor={data.publication?.preferences?.baseColor}
767/>
768
769<Account {data} />
770
771<Context {data} isEditing={true}>
772 <CardCommand
773 bind:open={showCardCommand}
774 onselect={(cardDef: CardDefinition) => {
775 if (cardDef.type === 'image') {
776 const input = document.getElementById('image-input') as HTMLInputElement;
777 if (input) {
778 input.click();
779 return;
780 }
781 } else if (cardDef.type === 'video') {
782 const input = document.getElementById('video-input') as HTMLInputElement;
783 if (input) {
784 input.click();
785 return;
786 }
787 } else {
788 newCard(cardDef.type);
789 }
790 }}
791 onlink={(url, cardDef) => {
792 addLink(url, cardDef);
793 }}
794 />
795
796 <Controls bind:data />
797
798 {#if showingMobileView}
799 <div
800 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full"
801 ></div>
802 {/if}
803
804 {#if newItem.modal && newItem.item}
805 <newItem.modal
806 oncreate={() => {
807 saveNewItem();
808 }}
809 bind:item={newItem.item}
810 oncancel={async () => {
811 newItem = {};
812 await tick();
813 cleanupDialogArtifacts();
814 }}
815 />
816 {/if}
817
818 <SaveModal
819 bind:open={showSaveModal}
820 success={saveSuccess}
821 handle={data.handle}
822 page={data.page}
823 />
824
825 <Modal open={showMobileWarning} closeButton={false}>
826 <div class="flex flex-col items-center gap-4 text-center">
827 <svg
828 xmlns="http://www.w3.org/2000/svg"
829 fill="none"
830 viewBox="0 0 24 24"
831 stroke-width="1.5"
832 stroke="currentColor"
833 class="text-accent-500 size-10"
834 >
835 <path
836 stroke-linecap="round"
837 stroke-linejoin="round"
838 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"
839 />
840 </svg>
841 <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p>
842 <p class="text-base-500 dark:text-base-400 text-sm">
843 Mobile editing is currently experimental. For the best experience, use a desktop browser.
844 </p>
845 <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button>
846 </div>
847 </Modal>
848
849 <div
850 class={[
851 '@container/wrapper relative w-full',
852 showingMobileView
853 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90'
854 : ''
855 ]}
856 >
857 {#if !getHideProfileSection(data)}
858 <EditableProfile bind:data hideBlento={showLoginOnEditPage} />
859 {/if}
860
861 <div
862 class={[
863 'pointer-events-none relative mx-auto max-w-lg',
864 !getHideProfileSection(data) && getProfilePosition(data) === 'side'
865 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4'
866 : '@5xl/wrapper:max-w-4xl'
867 ]}
868 >
869 <div class="pointer-events-none"></div>
870 <EditableGrid
871 bind:items
872 bind:ref={gridContainer}
873 {isMobile}
874 {selectedCardId}
875 {isCoarse}
876 onlayoutchange={onLayoutChanged}
877 ondeselect={() => {
878 selectedCardId = null;
879 }}
880 onfiledrop={handleFileDrop}
881 >
882 {#each items as item, i (item.id)}
883 <BaseEditingCard
884 bind:item={items[i]}
885 ondelete={() => {
886 items = items.filter((it) => it !== item);
887 compactItems(items, false);
888 compactItems(items, true);
889 onLayoutChanged();
890 }}
891 onsetsize={(newW: number, newH: number) => {
892 if (isMobile) {
893 item.mobileW = newW;
894 item.mobileH = newH;
895 } else {
896 item.w = newW;
897 item.h = newH;
898 }
899
900 fixCollisions(items, item, isMobile);
901 onLayoutChanged();
902 }}
903 >
904 <EditingCard bind:item={items[i]} />
905 </BaseEditingCard>
906 {/each}
907 </EditableGrid>
908 </div>
909 </div>
910
911 <EditBar
912 {data}
913 bind:linkValue
914 bind:isSaving
915 bind:showingMobileView
916 {hasUnsavedChanges}
917 {newCard}
918 {addLink}
919 {save}
920 {handleImageInputChange}
921 {handleVideoInputChange}
922 showCardCommand={() => {
923 showCardCommand = true;
924 }}
925 {selectedCard}
926 {isMobile}
927 {isCoarse}
928 ondeselect={() => {
929 selectedCardId = null;
930 }}
931 ondelete={() => {
932 if (selectedCard) {
933 items = items.filter((it) => it.id !== selectedCardId);
934 compactItems(items, false);
935 compactItems(items, true);
936 onLayoutChanged();
937 selectedCardId = null;
938 }
939 }}
940 onsetsize={(w: number, h: number) => {
941 if (selectedCard) {
942 if (isMobile) {
943 selectedCard.mobileW = w;
944 selectedCard.mobileH = h;
945 } else {
946 selectedCard.w = w;
947 selectedCard.h = h;
948 }
949 fixCollisions(items, selectedCard, isMobile);
950 onLayoutChanged();
951 }
952 }}
953 />
954
955 <Toaster />
956
957 <FloatingEditButton {data} />
958
959 {#if dev}
960 <div
961 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"
962 >
963 <span>editedOn: {editedOn}</span>
964 <button class="underline" onclick={addAllCardTypes}>+ all cards</button>
965 <input
966 bind:value={copyInput}
967 placeholder="handle/page"
968 class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5"
969 onkeydown={(e) => {
970 if (e.key === 'Enter') copyPageFrom();
971 }}
972 />
973 <button class="underline" onclick={copyPageFrom} disabled={isCopying}>
974 {isCopying ? 'copying...' : 'copy'}
975 </button>
976 </div>
977 {/if}
978</Context>